@segmentstream/agent 0.1.2 → 0.1.4

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
@@ -51,17 +51,15 @@ import { segmentstream } from "@segmentstream/agent";
51
51
 
52
52
  export default defineAgent({
53
53
  defaultTools: false,
54
- model: segmentstream.model({
55
- default: "spacexai/grok-4.7",
56
- byRole: { finance: "anthropic/claude-opus-5.5" },
57
- preview: "google/gemini-flash",
58
- }),
54
+ model: segmentstream.model(),
59
55
  reasoning: "medium",
60
56
  limits: { maxTokenCostUsdPerSession: 2 },
61
57
  });
62
58
  ```
63
59
 
64
- `segmentstream.model()` resolves on `session.started`. On a preview deployment it returns `preview`
60
+ `segmentstream.model()` resolves on `session.started` from `models.chat` in `agent/segmentstream.json`
61
+ (`{ "default", "byRole", "preview" }`), where the platform reads it too; an inline
62
+ `segmentstream.model({ default, byRole, preview })` works the same. On a preview deployment it returns `preview`
65
63
  when set. Otherwise it returns the model of the first `byRole` entry whose role the member holds, in
66
64
  the order written, and `default` when none matches. The roles come from the member's pairing, not
67
65
  from the message. The model is never switched mid-session, which keeps the provider's prompt cache.
@@ -118,7 +116,8 @@ share a group chat's session never share a client.
118
116
  proxy. The token stays in SegmentStream.
119
117
  - **Webhook.** A webhook is accepted only with `X-Telegram-Bot-Api-Secret-Token` equal to
120
118
  `SEGMENTSTREAM_AGENT_WEBHOOK_SECRET`, compared in constant time. The platform generates the
121
- secret and registers the webhook after each production deployment.
119
+ secret and registers the webhook after each production deployment, and sets the bot's menu
120
+ button, shown in private chats, to open the workspace app.
122
121
  - **Production only.** Outside `VERCEL_ENV=production` the route answers 200 and does nothing, so a
123
122
  preview never answers a real chat. Without `channels.telegram` it answers 404.
124
123
  - **Pairing.** Messages from bots and channel posts are dropped. The sender's Telegram account is
@@ -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 };
@@ -30,6 +30,16 @@ export interface SegmentStreamAgentConfigInput {
30
30
  };
31
31
  };
32
32
  readonly models?: {
33
+ /**
34
+ * The chat model `segmentstream.model()` chooses per session: `default` for everyone, one per
35
+ * member role in `byRole` (the first role the member holds wins, in the order written) and
36
+ * `preview` for preview deployments.
37
+ */
38
+ readonly chat?: {
39
+ readonly default: string;
40
+ readonly byRole?: Readonly<Record<string, string>>;
41
+ readonly preview?: string;
42
+ };
33
43
  /** A Gateway transcription model, or `false` to leave voice messages untranscribed. */
34
44
  readonly stt?: string | boolean;
35
45
  /** The Gateway decision model of the inbound router. */
@@ -39,23 +49,65 @@ export interface SegmentStreamAgentConfigInput {
39
49
  /** Whether the agent reaches the ontology over MCP. On by default. */
40
50
  readonly connection?: boolean;
41
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
+ };
42
68
  }
43
69
  export interface TelegramSettings {
44
70
  readonly connection: string;
45
71
  readonly policy: TelegramPolicy;
46
72
  }
73
+ /** The chat models `segmentstream.model()` chooses between, as AI Gateway model ids. */
74
+ export interface ChatModelChoice {
75
+ /** The model for every session no other rule picks. */
76
+ readonly default: string;
77
+ /**
78
+ * A model per member role. The first entry whose role the member holds wins, in the order
79
+ * written, so put the more specific roles first.
80
+ */
81
+ readonly byRole?: Readonly<Record<string, string>>;
82
+ /** The model of preview deployments, so a preview never spends on a large model. */
83
+ readonly preview?: string;
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
+ }
47
96
  /** The validated configuration with defaults applied. It stays JSON-serializable. */
48
97
  export interface SegmentStreamAgentConfig {
49
98
  readonly channels: {
50
99
  readonly telegram: TelegramSettings | null;
51
100
  };
52
101
  readonly models: {
102
+ /** `null` when `agent.ts` passes the choice to `segmentstream.model()` itself. */
103
+ readonly chat: ChatModelChoice | null;
53
104
  readonly stt: string | false;
54
105
  readonly decision: string;
55
106
  };
56
107
  readonly ontology: {
57
108
  readonly connection: boolean;
58
109
  };
110
+ readonly sessions: SessionSettings;
59
111
  }
60
112
  /** One validation failure, in Standard Schema's issue shape. */
61
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";
@@ -72,6 +73,110 @@ function modelId(input) {
72
73
  }
73
74
  return input.value;
74
75
  }
76
+ function chatModelChoice(value, issues) {
77
+ const path = ["models", "chat"];
78
+ const chat = section({
79
+ value,
80
+ path,
81
+ allowed: [
82
+ "default",
83
+ "byRole",
84
+ "preview"
85
+ ],
86
+ issues
87
+ });
88
+ if (chat === void 0) return null;
89
+ const fallback = "";
90
+ if (chat.default === void 0) issues.push({
91
+ message: "is required",
92
+ path: [...path, "default"]
93
+ });
94
+ const defaultModel = modelId({
95
+ value: chat.default,
96
+ fallback,
97
+ path: [...path, "default"],
98
+ issues
99
+ });
100
+ const byRole = section({
101
+ value: chat.byRole,
102
+ path: [...path, "byRole"],
103
+ allowed: Object.keys(isRecord(chat.byRole) ? chat.byRole : {}),
104
+ issues
105
+ });
106
+ const roles = Object.fromEntries(Object.entries(byRole ?? {}).map(([role, model]) => [role, modelId({
107
+ value: model,
108
+ fallback,
109
+ path: [
110
+ ...path,
111
+ "byRole",
112
+ role
113
+ ],
114
+ issues
115
+ })]));
116
+ const preview = chat.preview === void 0 ? void 0 : modelId({
117
+ value: chat.preview,
118
+ fallback,
119
+ path: [...path, "preview"],
120
+ issues
121
+ });
122
+ return {
123
+ default: defaultModel,
124
+ ...byRole === void 0 ? {} : { byRole: roles },
125
+ ...preview === void 0 ? {} : { preview }
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
+ }
75
180
  function validateConfig(input) {
76
181
  const issues = [];
77
182
  const root = section({
@@ -80,7 +185,8 @@ function validateConfig(input) {
80
185
  allowed: [
81
186
  "channels",
82
187
  "models",
83
- "ontology"
188
+ "ontology",
189
+ "sessions"
84
190
  ],
85
191
  issues
86
192
  });
@@ -93,7 +199,11 @@ function validateConfig(input) {
93
199
  const models = section({
94
200
  value: root?.models,
95
201
  path: ["models"],
96
- allowed: ["stt", "decision"],
202
+ allowed: [
203
+ "chat",
204
+ "stt",
205
+ "decision"
206
+ ],
97
207
  issues
98
208
  });
99
209
  const ontology = section({
@@ -103,6 +213,7 @@ function validateConfig(input) {
103
213
  issues
104
214
  });
105
215
  const telegram = telegramSettings(channels?.telegram, issues);
216
+ const chat = chatModelChoice(models?.chat, issues);
106
217
  const stt = models?.stt === false ? false : modelId({
107
218
  value: models?.stt,
108
219
  fallback: DEFAULT_STT_MODEL,
@@ -116,6 +227,7 @@ function validateConfig(input) {
116
227
  issues
117
228
  });
118
229
  const connection = ontology?.connection ?? true;
230
+ const sessions = sessionSettings(root?.sessions, issues);
119
231
  if (typeof connection !== "boolean") issues.push({
120
232
  message: "must be true or false",
121
233
  path: ["ontology", "connection"]
@@ -124,10 +236,12 @@ function validateConfig(input) {
124
236
  return { value: {
125
237
  channels: { telegram },
126
238
  models: {
239
+ chat,
127
240
  stt,
128
241
  decision
129
242
  },
130
- ontology: { connection: connection === true }
243
+ ontology: { connection: connection === true },
244
+ sessions
131
245
  } };
132
246
  }
133
247
  const configSchema = { "~standard": {
@@ -141,4 +255,4 @@ function manifestOf(config) {
141
255
  if (validation.issues !== void 0) throw new SegmentStreamAgentConfigError(validation.issues);
142
256
  return validation.value;
143
257
  }
144
- 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 };
@@ -1,5 +1,5 @@
1
1
  import { manifestOf } from "./config.js";
2
- import { model } from "./model.js";
2
+ import { type DynamicModel, type ModelChoice } from "./model.js";
3
3
  export declare const segmentstream: import("eve/extension").ExtensionHandle<{
4
4
  readonly "~standard": {
5
5
  readonly version: 1;
@@ -11,9 +11,9 @@ export declare const segmentstream: import("eve/extension").ExtensionHandle<{
11
11
  };
12
12
  };
13
13
  }> & {
14
- model: typeof model;
14
+ model: (choice?: ModelChoice) => DynamicModel;
15
15
  manifestOf: typeof manifestOf;
16
16
  };
17
17
  export default segmentstream;
18
- export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, manifestOf, 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";
@@ -9,7 +9,7 @@ import { chooseModel, model } from "./model.mjs";
9
9
  import { defineExtension } from "eve/extension";
10
10
  const extension = defineExtension({ config: configSchema });
11
11
  const segmentstream = Object.assign(extension, {
12
- model,
12
+ model: (choice) => model(choice ?? (() => extension.config.models.chat)),
13
13
  manifestOf
14
14
  });
15
15
  export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, chooseModel, segmentstream as default, segmentstream, manifestOf, model };
@@ -1,9 +1,10 @@
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
4
  import { type RuntimeToken } from "../runtime-token.js";
5
5
  import { type ObservedMessages } from "./observed.js";
6
6
  import { type DecideFunction } from "./router.js";
7
+ import { type HistoryReader } from "./sessions.js";
7
8
  import { type Transcriber } from "./voice.js";
8
9
  /**
9
10
  * The SegmentStream Telegram adapter: eve's `telegramChannel`, with its credentials, identity,
@@ -17,6 +18,8 @@ export interface TelegramAdapterOptions {
17
18
  readonly stt: string | false;
18
19
  readonly decision: string;
19
20
  };
21
+ /** The mounted `sessions`. */
22
+ readonly sessions: () => SessionSettings;
20
23
  readonly decide: DecideFunction;
21
24
  readonly environment?: Environment;
22
25
  readonly token?: RuntimeToken;
@@ -24,6 +27,8 @@ export interface TelegramAdapterOptions {
24
27
  readonly fetch?: typeof fetch;
25
28
  readonly transcribe?: Transcriber;
26
29
  readonly observed?: ObservedMessages;
30
+ /** Reads the tail of a session's durable stream; tests pass a fake. */
31
+ readonly history?: HistoryReader;
27
32
  }
28
33
  /** The Telegram channel of the extension. */
29
34
  export declare function segmentstreamTelegram(options: TelegramAdapterOptions): TelegramChannel;
@@ -11,6 +11,7 @@ import { runtimeToken } from "../runtime-token.mjs";
11
11
  import { botIdentityCache } from "./bot.mjs";
12
12
  import { observedMessages } from "./observed.mjs";
13
13
  import { decisionRouter } from "./router.mjs";
14
+ import { scopedAddress, sessionPolicy } from "./sessions.mjs";
14
15
  import { downloadTelegramFile, gatewayTranscriber } from "./voice.mjs";
15
16
  import { handleInboundMessage } from "./inbound.mjs";
16
17
  import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
@@ -18,10 +19,26 @@ import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch }
18
19
  import { deliverUnlessSilent } from "./silent.mjs";
19
20
  import { telegramWebhookVerifier } from "./webhook.mjs";
20
21
  import { telegramChannel } from "eve/channels/telegram";
22
+ const eveHistory = async (sessionId) => {
23
+ const { sessions } = await import("eve/server");
24
+ const events = [];
25
+ for await (const event of sessions.attach(sessionId).stream({
26
+ startIndex: -400,
27
+ follow: false
28
+ })) events.push({
29
+ type: event.type,
30
+ meta: { at: event.meta.at }
31
+ });
32
+ return events;
33
+ };
21
34
  function segmentstreamTelegram(options) {
22
35
  const environment = options.environment ?? process.env;
23
36
  const token = options.token ?? runtimeToken;
24
37
  const observed = options.observed ?? observedMessages();
38
+ const policy = sessionPolicy({
39
+ settings: options.sessions,
40
+ read: options.history ?? eveHistory
41
+ });
25
42
  const bot = botIdentityCache();
26
43
  const proxies = new Map();
27
44
  let identities;
@@ -69,6 +86,8 @@ function segmentstreamTelegram(options) {
69
86
  request: currentInboundRequest(),
70
87
  dependencies: {
71
88
  policy: settings().policy,
89
+ sessions: options.sessions,
90
+ sessionPolicy: policy,
72
91
  sttModel: options.models().stt,
73
92
  identities: pairing(),
74
93
  bot,
@@ -105,9 +124,14 @@ function gatedRoute(input) {
105
124
  if (!channelsEnabled(environment)) return new Response("ok");
106
125
  if (options.settings() === null) return new Response("not found", { status: 404 });
107
126
  const inbound = new InboundRequest(async () => (await args.describe()).description ?? "");
127
+ const scoped = (address) => scopedAddress(address, options.sessions().scope);
128
+ inbound.sessions = {
129
+ resolveSession: args.resolveSession,
130
+ from: args.from
131
+ };
108
132
  return runInboundRequest(inbound, () => route.handler(request, {
109
133
  ...args,
110
- from: (address) => withReplacedText(args.from(address), inbound)
134
+ from: (address) => withReplacedText(args.from(scoped(address)), inbound)
111
135
  }));
112
136
  }
113
137
  };
@@ -1,4 +1,5 @@
1
1
  import type { ChannelSource } from "eve/channels";
2
+ import type { SessionOperations } from "./sessions.js";
2
3
  /**
3
4
  * What one webhook request shares between eve's `onMessage` hook and the route wrapper around it.
4
5
  * `onMessage` may only return `{ auth, context, title }`; it cannot change the message eve sends.
@@ -11,6 +12,8 @@ export declare class InboundRequest {
11
12
  readonly agentDescription: () => Promise<string>;
12
13
  /** The text the turn should carry instead of the parsed message, once a transcript exists. */
13
14
  text: string | undefined;
15
+ /** The route's session operations, on addresses already mapped to the configured scope. */
16
+ sessions: SessionOperations | undefined;
14
17
  constructor(
15
18
  /** The agent's description from the compiled manifest, for the routing decision. */
16
19
  agentDescription: () => Promise<string>);
@@ -8,6 +8,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
8
8
  var InboundRequest = class {
9
9
  agentDescription;
10
10
  text;
11
+ sessions;
11
12
  constructor(agentDescription) {
12
13
  this.agentDescription = agentDescription;
13
14
  }
@@ -1,10 +1,11 @@
1
1
  import type { TelegramContext, TelegramInboundResult, TelegramMessage } from "eve/channels/telegram";
2
- import type { TelegramPolicy } from "../../config.js";
2
+ import type { SessionSettings, TelegramPolicy } from "../../config.js";
3
3
  import type { AgentIdentities } from "../identities.js";
4
4
  import type { BotIdentityLookup } from "./bot.js";
5
5
  import type { InboundRequest } from "./inbound-request.js";
6
6
  import { type ObservedMessages } from "./observed.js";
7
7
  import { type RouteDecider } from "./router.js";
8
+ import { type SessionPolicy } from "./sessions.js";
8
9
  import { type Transcriber } from "./voice.js";
9
10
  /**
10
11
  * eve's Telegram `onMessage` hook, in the order `agent-design.md` gives:
@@ -13,10 +14,15 @@ import { type Transcriber } from "./voice.js";
13
14
  * start a turn. An unknown sender who writes to the bot privately gets a pairing button.
14
15
  * 2. Voice becomes text, so the router and the agent read what was said.
15
16
  * 3. The router answers or observes. An observed message goes to the chat's ring and stops here.
16
- * 4. An answered message dispatches as the paired member, with the ring ahead of it as context.
17
+ * 4. The session policy runs on the session the message continues: an idle one is cleared, the
18
+ * first message after the day's compaction time compacts it.
19
+ * 5. An answered message dispatches as the paired member, with the ring and a `<speaker>` block
20
+ * naming the member, their roles and the message they replied to ahead of it as context.
17
21
  */
18
22
  export interface InboundDependencies {
19
23
  readonly policy: TelegramPolicy;
24
+ readonly sessions: () => SessionSettings;
25
+ readonly sessionPolicy: SessionPolicy;
20
26
  readonly sttModel: string | false;
21
27
  readonly identities: AgentIdentities;
22
28
  readonly bot: BotIdentityLookup;
@@ -7,10 +7,12 @@ __eveCreateRequire(import.meta.url);
7
7
  import { telegramPrincipal } from "./principal.mjs";
8
8
  import { observedChatKey, observedContext } from "./observed.mjs";
9
9
  import { isAddressed, routeMessage } from "./router.mjs";
10
+ import { messageAddress, scopedAddress } from "./sessions.mjs";
10
11
  import { voiceNote, voiceOf, voiceText } from "./voice.mjs";
11
12
  const UNKNOWN_PRIVATE = "Hi, I don't know you yet. Connect your Telegram account to the workspace and I'll act as you. The button works for ten minutes.";
12
13
  const UNKNOWN_IN_GROUP = "I don't know you yet. Message me privately to connect your account.";
13
14
  const REFUSED = "Your access to this agent was revoked, or your membership is inactive. Ask a workspace administrator.";
15
+ const MAXIMUM_QUOTE_LENGTH = 500;
14
16
  function buttonTo(text, url) {
15
17
  return { inline_keyboard: [[{
16
18
  text,
@@ -26,13 +28,41 @@ function sentAt(message) {
26
28
  const date = message.raw.date;
27
29
  return new Date(typeof date === "number" ? date * 1e3 : Date.now()).toISOString();
28
30
  }
29
- function observedMessage(message, text) {
31
+ function authorOf(user, member) {
32
+ const name = display(user) ?? user.id;
33
+ if (member === void 0) return name;
34
+ const roles = member.roles.length === 0 ? "" : ` (${member.roles.join(", ")})`;
35
+ return member.name === null ? `${name}${roles}` : `${member.name}${roles}, ${name}`;
36
+ }
37
+ function observedMessage(message, text, member) {
30
38
  return {
31
- author: display(message.from) ?? message.from.id,
39
+ author: authorOf(message.from, member),
32
40
  text,
33
41
  at: sentAt(message)
34
42
  };
35
43
  }
44
+ function isRecord(value) {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+ function speakerBlock(input) {
48
+ const { message, member, bot } = input;
49
+ const roles = member.roles.length === 0 ? "no roles" : member.roles.join(", ");
50
+ const lines = [
51
+ "<speaker>",
52
+ `member: ${member.name ?? "unnamed member"} (${roles})`,
53
+ `telegram: ${display(message.from) ?? "no name"}, user_id ${message.from.id}`
54
+ ];
55
+ const raw = message.raw.reply_to_message;
56
+ if (isRecord(raw)) {
57
+ const from = isRecord(raw.from) ? raw.from : {};
58
+ const author = from.is_bot === true && bot !== void 0 && String(from.id) === bot.id ? "the agent" : typeof from.username === "string" ? `@${from.username}` : typeof from.first_name === "string" ? from.first_name : "unknown sender";
59
+ const text = typeof raw.text === "string" ? raw.text : typeof raw.caption === "string" ? raw.caption : "";
60
+ const quoted = text.length > MAXIMUM_QUOTE_LENGTH ? `${text.slice(0, MAXIMUM_QUOTE_LENGTH)}…` : text;
61
+ lines.push(`reply_to: ${author}: ${JSON.stringify(quoted)}`);
62
+ }
63
+ lines.push("</speaker>");
64
+ return lines.join("\n");
65
+ }
36
66
  function unknownInGroup(bot) {
37
67
  return bot?.username === void 0 ? { text: UNKNOWN_IN_GROUP } : {
38
68
  text: UNKNOWN_IN_GROUP,
@@ -84,6 +114,8 @@ async function handleInboundMessage(input) {
84
114
  });
85
115
  return null;
86
116
  }
117
+ const address = scopedAddress(messageAddress(sent), dependencies.sessions().scope);
118
+ const operations = input.request?.sessions;
87
119
  const voice = voiceOf(sent.raw);
88
120
  const caption = sent.text || sent.caption;
89
121
  const notes = [];
@@ -110,21 +142,38 @@ async function handleInboundMessage(input) {
110
142
  question: async () => ({
111
143
  agent: await input.request?.agentDescription() ?? "",
112
144
  observed: dependencies.observed.list(chat),
113
- message: observedMessage(sent, text)
145
+ message: observedMessage(sent, text, lookup.member)
114
146
  })
115
147
  }) === "observe") {
116
- dependencies.observed.add(chat, observedMessage(sent, text === "" ? "[attachment]" : text));
148
+ dependencies.observed.add(chat, observedMessage(sent, text === "" ? "[attachment]" : text, lookup.member));
117
149
  return null;
118
150
  }
119
151
  if (voice !== void 0 && input.request !== void 0) input.request.text = text;
120
152
  else if (voice !== void 0) notes.push(text);
153
+ if (operations !== void 0) {
154
+ const newest = dependencies.observed.list(chat).at(-1);
155
+ if (await dependencies.sessionPolicy.apply({
156
+ address,
157
+ operations,
158
+ ...newest === void 0 ? {} : { activeAt: Date.parse(newest.at) || 0 }
159
+ }) === "cleared") dependencies.observed.take(chat);
160
+ dependencies.sessionPolicy.delivered(address);
161
+ }
121
162
  await context.telegram.startTyping();
122
163
  return {
123
164
  auth: telegramPrincipal({
124
165
  message: sent,
125
166
  member: lookup.member
126
167
  }),
127
- context: [...observedContext(dependencies.observed.take(chat)), ...notes]
168
+ context: [
169
+ ...observedContext(dependencies.observed.take(chat)),
170
+ speakerBlock({
171
+ message: sent,
172
+ member: lookup.member,
173
+ bot
174
+ }),
175
+ ...notes
176
+ ]
128
177
  };
129
178
  }
130
179
  export { handleInboundMessage };
@@ -0,0 +1,84 @@
1
+ import type { TelegramMessage } from "eve/channels/telegram";
2
+ import type { SessionSettings } from "../../config.js";
3
+ /**
4
+ * Session policy for the Telegram channel (`agent-design.md`, section 4, and docs/agent.md,
5
+ * "Sessions"): what a group session spans, the daily compaction and the idle reset. The policy
6
+ * runs in the webhook route, before eve delivers a message, with the operations the route offers
7
+ * on a continuation address (`resolveSession`, `compact`, `clear`); the history it needs is read
8
+ * from the session's own durable stream, so nothing is stored beside eve.
9
+ */
10
+ /** eve's Telegram continuation address: `chat:thread:conversation`, the last two possibly empty. */
11
+ export interface TelegramAddress {
12
+ readonly chatId: string;
13
+ readonly messageThreadId: string;
14
+ readonly conversationId: string;
15
+ }
16
+ export declare function parseAddress(address: string): TelegramAddress;
17
+ export declare function formatAddress(address: TelegramAddress): string;
18
+ /**
19
+ * The address eve derives from an inbound message: the chat, the forum topic and, in a group, the
20
+ * reply thread (a reply to the bot continues the bot's message, anything else starts its own).
21
+ */
22
+ export declare function messageAddress(message: TelegramMessage): string;
23
+ /**
24
+ * The address a message continues under the configured scope. `chat` drops eve's reply-thread
25
+ * anchor, so every message of a group chat (and forum topic) reaches one session; `thread` keeps
26
+ * eve's own address. A private chat has no anchor either way.
27
+ */
28
+ export declare function scopedAddress(address: string, scope: SessionSettings["scope"]): string;
29
+ /** The most recent `HH:MM` in `timezone` at or before `now`. */
30
+ export declare function compactBoundary(now: Date, compactAt: string, timezone: string): Date;
31
+ /** What the policy knows about one session: from its stream, then kept per process. */
32
+ export interface SessionHistory {
33
+ /** When the session last did anything. */
34
+ readonly lastAt: number;
35
+ /** When its history last started afresh: creation, compaction or clear. */
36
+ readonly resetAt: number;
37
+ /** Turns completed since `resetAt`; a compaction is worth nothing before the first. */
38
+ readonly turnsSinceReset: number;
39
+ }
40
+ /** One recorded event, as much of eve's envelope as the policy reads. */
41
+ export interface RecordedEvent {
42
+ readonly type: string;
43
+ readonly meta: {
44
+ readonly at: string;
45
+ };
46
+ }
47
+ /** The last events of a session, oldest first. Production reads eve's durable stream. */
48
+ export type HistoryReader = (sessionId: string) => Promise<ReadonlyArray<RecordedEvent>>;
49
+ /** How many events of the tail the reader fetches: enough for a day of a busy group. */
50
+ export declare const HISTORY_TAIL = 400;
51
+ /** Folds a tail into a history; with no reset marker in it, `resetAt` is its earliest event. */
52
+ export declare function historyOf(events: ReadonlyArray<RecordedEvent>): SessionHistory;
53
+ /** The route's operations on a continuation address, as eve gives them to the handler. */
54
+ export interface SessionOperations {
55
+ readonly resolveSession: (address: string) => Promise<{
56
+ readonly id: string;
57
+ } | undefined>;
58
+ readonly from: (address: string) => {
59
+ compact(): Promise<unknown>;
60
+ clear(): Promise<unknown>;
61
+ };
62
+ }
63
+ export type SessionAction = "none" | "cleared" | "compacted";
64
+ export interface SessionPolicy {
65
+ /**
66
+ * Applies the policy to the session `address` continues, before a message is delivered to it:
67
+ * clears a history idle for longer than `idleResetHours`, else compacts it once after each
68
+ * day's `compactAt`. A session that does not exist yet needs neither.
69
+ */
70
+ apply(input: {
71
+ readonly address: string;
72
+ readonly operations: SessionOperations;
73
+ /** The newest activity the stream does not show, such as an observed group message. */
74
+ readonly activeAt?: number;
75
+ readonly now?: Date;
76
+ }): Promise<SessionAction>;
77
+ /** Records that a message was just delivered to the session. */
78
+ delivered(address: string, now?: Date): void;
79
+ }
80
+ export declare function sessionPolicy(input: {
81
+ readonly settings: () => SessionSettings;
82
+ readonly read: HistoryReader;
83
+ readonly log?: (message: string, error: unknown) => void;
84
+ }): SessionPolicy;
@@ -0,0 +1,200 @@
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
+ function parseAddress(address) {
8
+ const [chatId = "", messageThreadId = "", conversationId = ""] = address.split(":");
9
+ return {
10
+ chatId,
11
+ messageThreadId,
12
+ conversationId
13
+ };
14
+ }
15
+ function formatAddress(address) {
16
+ return `${address.chatId}:${address.messageThreadId}:${address.conversationId}`;
17
+ }
18
+ function messageAddress(message) {
19
+ const group = message.chat.type === "group" || message.chat.type === "supergroup";
20
+ const reply = message.replyToMessage;
21
+ const conversationId = !group ? "" : reply?.from?.isBot === true ? reply.messageId : message.messageId;
22
+ return formatAddress({
23
+ chatId: message.chat.id,
24
+ messageThreadId: message.messageThreadId === void 0 ? "" : String(message.messageThreadId),
25
+ conversationId
26
+ });
27
+ }
28
+ function scopedAddress(address, scope) {
29
+ if (scope === "thread") return address;
30
+ return formatAddress({
31
+ ...parseAddress(address),
32
+ conversationId: ""
33
+ });
34
+ }
35
+ function localParts(timezone, date) {
36
+ const parts = new Intl.DateTimeFormat("en-US", {
37
+ timeZone: timezone,
38
+ hourCycle: "h23",
39
+ year: "numeric",
40
+ month: "2-digit",
41
+ day: "2-digit",
42
+ hour: "2-digit",
43
+ minute: "2-digit",
44
+ second: "2-digit"
45
+ }).formatToParts(date);
46
+ const value = (type) => Number(parts.find((part) => part.type === type)?.value ?? "0");
47
+ return {
48
+ year: value("year"),
49
+ month: value("month"),
50
+ day: value("day"),
51
+ hour: value("hour"),
52
+ minute: value("minute"),
53
+ second: value("second")
54
+ };
55
+ }
56
+ function offsetMinutes(timezone, date) {
57
+ const local = localParts(timezone, date);
58
+ const asUtc = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second);
59
+ return Math.round((asUtc - date.getTime()) / 6e4);
60
+ }
61
+ function instantOf(timezone, local) {
62
+ const guess = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute);
63
+ const first = new Date(guess - offsetMinutes(timezone, new Date(guess)) * 6e4);
64
+ const second = new Date(guess - offsetMinutes(timezone, first) * 6e4);
65
+ const back = localParts(timezone, second);
66
+ if (back.hour !== local.hour || back.minute !== local.minute) return first > second ? first : second;
67
+ return second;
68
+ }
69
+ function compactBoundary(now, compactAt, timezone) {
70
+ const [hour = 0, minute = 0] = compactAt.split(":").map(Number);
71
+ const today = localParts(timezone, now);
72
+ const candidate = instantOf(timezone, {
73
+ ...today,
74
+ hour,
75
+ minute
76
+ });
77
+ if (candidate.getTime() <= now.getTime()) return candidate;
78
+ const yesterday = new Date(Date.UTC(today.year, today.month - 1, today.day - 1));
79
+ return instantOf(timezone, {
80
+ year: yesterday.getUTCFullYear(),
81
+ month: yesterday.getUTCMonth() + 1,
82
+ day: yesterday.getUTCDate(),
83
+ hour,
84
+ minute
85
+ });
86
+ }
87
+ const HISTORY_TAIL = 400;
88
+ function historyOf(events) {
89
+ let lastAt = 0;
90
+ let resetAt = 0;
91
+ let earliest = 0;
92
+ let marked = false;
93
+ let turnsSinceReset = 0;
94
+ for (const event of events) {
95
+ const at = Date.parse(event.meta.at);
96
+ if (Number.isNaN(at)) continue;
97
+ lastAt = Math.max(lastAt, at);
98
+ earliest = earliest === 0 ? at : Math.min(earliest, at);
99
+ if (event.type === "session.started" || event.type === "compaction.completed" || event.type === "context.cleared") {
100
+ resetAt = at;
101
+ marked = true;
102
+ turnsSinceReset = 0;
103
+ } else if (event.type === "turn.completed") turnsSinceReset += 1;
104
+ }
105
+ return {
106
+ lastAt,
107
+ resetAt: marked ? resetAt : earliest,
108
+ turnsSinceReset
109
+ };
110
+ }
111
+ const MAXIMUM_REMEMBERED = 500;
112
+ function sessionPolicy(input) {
113
+ const remembered = new Map();
114
+ const log = input.log ?? ((message, error) => console.warn(message, error));
115
+ function remember(address, history) {
116
+ remembered.delete(address);
117
+ remembered.set(address, history);
118
+ if (remembered.size > MAXIMUM_REMEMBERED) {
119
+ const oldest = remembered.keys().next();
120
+ if (oldest.done !== true) remembered.delete(oldest.value);
121
+ }
122
+ }
123
+ async function history(address, operations, fresh = false) {
124
+ const known = fresh ? void 0 : remembered.get(address);
125
+ if (known !== void 0) return known;
126
+ const session = await operations.resolveSession(address);
127
+ if (session === void 0) {
128
+ remembered.delete(address);
129
+ return;
130
+ }
131
+ const read = historyOf(await input.read(session.id));
132
+ remember(address, read);
133
+ return read;
134
+ }
135
+ function due(known, settings, now, activeAt) {
136
+ const idle = settings.idleResetHours;
137
+ const lastAt = Math.max(known.lastAt, activeAt);
138
+ if (idle !== false && now.getTime() - lastAt >= idle * 36e5) return "clear";
139
+ if (settings.compactAt !== false) {
140
+ const boundary = compactBoundary(now, settings.compactAt, settings.timezone).getTime();
141
+ if (known.resetAt < boundary && known.turnsSinceReset > 0) return "compact";
142
+ }
143
+ return "none";
144
+ }
145
+ return {
146
+ async apply({ address, operations, activeAt = 0, now = new Date() }) {
147
+ const settings = input.settings();
148
+ const cached = remembered.has(address);
149
+ let known;
150
+ try {
151
+ known = await history(address, operations);
152
+ } catch (error) {
153
+ log("The session policy could not read the session's history; delivering as is.", error);
154
+ return "none";
155
+ }
156
+ if (known === void 0) return "none";
157
+ let action = due(known, settings, now, activeAt);
158
+ if (action !== "none" && cached) {
159
+ try {
160
+ known = await history(address, operations, true);
161
+ } catch (error) {
162
+ log("The session policy could not re-read the session's history; delivering as is.", error);
163
+ return "none";
164
+ }
165
+ if (known === void 0) return "none";
166
+ action = due(known, settings, now, activeAt);
167
+ }
168
+ if (action === "clear") try {
169
+ await operations.from(address).clear();
170
+ remember(address, {
171
+ lastAt: known.lastAt,
172
+ resetAt: now.getTime(),
173
+ turnsSinceReset: 0
174
+ });
175
+ return "cleared";
176
+ } catch (error) {
177
+ log("The idle reset could not clear the session; delivering as is.", error);
178
+ return "none";
179
+ }
180
+ if (action === "compact") try {
181
+ await operations.from(address).compact();
182
+ remembered.delete(address);
183
+ return "compacted";
184
+ } catch (error) {
185
+ log("The daily compaction could not be queued; delivering as is.", error);
186
+ }
187
+ return "none";
188
+ },
189
+ delivered(address, now = new Date()) {
190
+ const known = remembered.get(address);
191
+ if (known === void 0) return;
192
+ remember(address, {
193
+ lastAt: now.getTime(),
194
+ resetAt: known.resetAt,
195
+ turnsSinceReset: known.turnsSinceReset + 1
196
+ });
197
+ }
198
+ };
199
+ }
200
+ export { HISTORY_TAIL, compactBoundary, formatAddress, historyOf, messageAddress, parseAddress, scopedAddress, sessionPolicy };
@@ -1,18 +1,9 @@
1
1
  import { type AgentModelDefinition, type AgentStaticModelDefinition } from "eve";
2
2
  import type { SessionAuthContext } from "eve/context";
3
+ import type { ChatModelChoice } from "./config.js";
3
4
  import { type Environment } from "./lib/environment.js";
4
5
  /** The models `segmentstream.model()` chooses between, as AI Gateway model ids. */
5
- export interface ModelChoice {
6
- /** The model for every session no other rule picks. */
7
- readonly default: string;
8
- /**
9
- * A model per member role. The first entry whose role the member holds wins, in the order
10
- * written, so put the more specific roles first.
11
- */
12
- readonly byRole?: Readonly<Record<string, string>>;
13
- /** The model of preview deployments, so a preview never spends on a large model. */
14
- readonly preview?: string;
15
- }
6
+ export type ModelChoice = ChatModelChoice;
16
7
  /** The dynamic form of an agent's `model`, which `defineAgent` accepts as the whole field. */
17
8
  export type DynamicModel = Exclude<AgentModelDefinition, AgentStaticModelDefinition>;
18
9
  /** The one model for a session started by `principal`. */
@@ -24,6 +15,8 @@ export declare function chooseModel(input: {
24
15
  /**
25
16
  * The agent's model, chosen once when a session starts from the member's roles and `VERCEL_ENV`.
26
17
  * Choosing once keeps the provider's prompt cache: a switch mid-session re-reads the history at
27
- * full price.
18
+ * full price. The choice is given here, or read when the session starts, from `models.chat` of
19
+ * `agent/segmentstream.json` through the mounted extension; a session without either fails to
20
+ * start with a message naming the missing setting.
28
21
  */
29
- export declare function model(choice: ModelChoice): DynamicModel;
22
+ export declare function model(choice: ModelChoice | (() => ModelChoice | null)): DynamicModel;
@@ -15,9 +15,13 @@ function chooseModel(input) {
15
15
  return choice.default;
16
16
  }
17
17
  function model(choice) {
18
- return defineDynamic({ events: { "session.started": (_event, context) => chooseModel({
19
- choice,
20
- principal: context.session.auth.current ?? context.session.auth.initiator
21
- }) } });
18
+ return defineDynamic({ events: { "session.started": (_event, context) => {
19
+ const chosen = typeof choice === "function" ? choice() : choice;
20
+ if (chosen === null) throw new Error("segmentstream.model() has no model to choose from: set models.chat in agent/segmentstream.json or pass the choice to segmentstream.model().");
21
+ return chooseModel({
22
+ choice: chosen,
23
+ principal: context.session.auth.current ?? context.session.auth.initiator
24
+ });
25
+ } } });
22
26
  }
23
27
  export { chooseModel, model };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream/agent",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",