@segmentstream/agent 0.1.1 → 0.1.3

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
@@ -18,31 +18,32 @@ npm install @segmentstream/agent eve@0.76.3
18
18
 
19
19
  ## Set up
20
20
 
21
- The workspace's `agent/` directory is an eve app in eve's flat layout, a package of its own like
22
- `ontology/`:
21
+ The workspace's `agent/` directory is an eve app in eve's documented layout, a package of its own
22
+ like `ontology/`: the package files at the root, the authored files under `agent/agent/`:
23
23
 
24
24
  ```text
25
25
  agent/
26
26
  ├── package.json # eve and @segmentstream/agent, both pinned; "build": "eve build"
27
27
  ├── segmentstream.json # what the extension mounts, and what the platform reads at deploy
28
- ├── agent.ts # model, reasoning, limits
29
- ├── instructions.md # who the company is, how the agent behaves
30
- └── extensions/
31
- └── segmentstream.ts # mounts this package with segmentstream.json
28
+ └── agent/ # eve's agent directory
29
+ ├── agent.ts # model, reasoning, limits
30
+ ├── instructions.md # who the company is, how the agent behaves
31
+ └── extensions/
32
+ └── segmentstream.ts # mounts this package with segmentstream.json
32
33
  ```
33
34
 
34
- **`extensions/segmentstream.ts`** mounts the extension. The file name is the mount's namespace, so
35
+ **`agent/agent/extensions/segmentstream.ts`** mounts the extension. The file name is the mount's namespace, so
35
36
  every contribution is named `segmentstream__…`:
36
37
 
37
38
  ```ts
38
39
  import { segmentstream } from "@segmentstream/agent/extension";
39
40
 
40
- import config from "../segmentstream.json";
41
+ import config from "../../segmentstream.json";
41
42
 
42
43
  export default segmentstream(config);
43
44
  ```
44
45
 
45
- **`agent.ts`** chooses the model once per session:
46
+ **`agent/agent/agent.ts`** chooses the model once per session:
46
47
 
47
48
  ```ts
48
49
  import { defineAgent } from "eve";
@@ -50,17 +51,15 @@ import { segmentstream } from "@segmentstream/agent";
50
51
 
51
52
  export default defineAgent({
52
53
  defaultTools: false,
53
- model: segmentstream.model({
54
- default: "spacexai/grok-4.7",
55
- byRole: { finance: "anthropic/claude-opus-5.5" },
56
- preview: "google/gemini-flash",
57
- }),
54
+ model: segmentstream.model(),
58
55
  reasoning: "medium",
59
56
  limits: { maxTokenCostUsdPerSession: 2 },
60
57
  });
61
58
  ```
62
59
 
63
- `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`
64
63
  when set. Otherwise it returns the model of the first `byRole` entry whose role the member holds, in
65
64
  the order written, and `default` when none matches. The roles come from the member's pairing, not
66
65
  from the message. The model is never switched mid-session, which keeps the provider's prompt cache.
@@ -117,14 +116,16 @@ share a group chat's session never share a client.
117
116
  proxy. The token stays in SegmentStream.
118
117
  - **Webhook.** A webhook is accepted only with `X-Telegram-Bot-Api-Secret-Token` equal to
119
118
  `SEGMENTSTREAM_AGENT_WEBHOOK_SECRET`, compared in constant time. The platform generates the
120
- 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.
121
121
  - **Production only.** Outside `VERCEL_ENV=production` the route answers 200 and does nothing, so a
122
122
  preview never answers a real chat. Without `channels.telegram` it answers 404.
123
123
  - **Pairing.** Messages from bots and channel posts are dropped. The sender's Telegram account is
124
124
  looked up with the ontology service on every message, so a revocation stops at once. An unknown
125
- sender who writes privately gets a link to `/agent/connect?code=…`, valid for ten minutes. In a
126
- group, the agent only asks an unknown sender to message it privately, because anyone in the group
127
- could open a link posted there. A revoked pairing or an inactive member gets a short refusal.
125
+ sender who writes privately gets a message with a **Connect account** button that opens
126
+ `/agent/connect?code=…`, valid for ten minutes. In a group, the agent only asks an unknown sender to
127
+ message it privately, with a button to the bot's chat, because anyone in the group could open a
128
+ pairing link posted there. A revoked pairing or an inactive member gets a short refusal.
128
129
  - **Who the agent is.** A paired sender's turn runs with the principal id `telegram:<user>`
129
130
  (`telegram:<chat>:<user>` in groups) and the attributes `memberId`, `memberName`, `roles`,
130
131
  `chatId`, `chatType` and `username`.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The extension's configuration: `agent/segmentstream.json`, mounted by
3
- * `agent/extensions/segmentstream.ts` and read by the platform at deploy.
3
+ * `agent/agent/extensions/segmentstream.ts` and read by the platform at deploy.
4
4
  *
5
5
  * eve validates a mount's configuration with a Standard Schema. The schema below is written by
6
6
  * hand, so the package adds no second schema library next to the Effect Schema the rest of the
@@ -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. */
@@ -44,12 +54,26 @@ export interface TelegramSettings {
44
54
  readonly connection: string;
45
55
  readonly policy: TelegramPolicy;
46
56
  }
57
+ /** The chat models `segmentstream.model()` chooses between, as AI Gateway model ids. */
58
+ export interface ChatModelChoice {
59
+ /** The model for every session no other rule picks. */
60
+ readonly default: string;
61
+ /**
62
+ * A model per member role. The first entry whose role the member holds wins, in the order
63
+ * written, so put the more specific roles first.
64
+ */
65
+ readonly byRole?: Readonly<Record<string, string>>;
66
+ /** The model of preview deployments, so a preview never spends on a large model. */
67
+ readonly preview?: string;
68
+ }
47
69
  /** The validated configuration with defaults applied. It stays JSON-serializable. */
48
70
  export interface SegmentStreamAgentConfig {
49
71
  readonly channels: {
50
72
  readonly telegram: TelegramSettings | null;
51
73
  };
52
74
  readonly models: {
75
+ /** `null` when `agent.ts` passes the choice to `segmentstream.model()` itself. */
76
+ readonly chat: ChatModelChoice | null;
53
77
  readonly stt: string | false;
54
78
  readonly decision: string;
55
79
  };
@@ -72,6 +72,58 @@ function modelId(input) {
72
72
  }
73
73
  return input.value;
74
74
  }
75
+ function chatModelChoice(value, issues) {
76
+ const path = ["models", "chat"];
77
+ const chat = section({
78
+ value,
79
+ path,
80
+ allowed: [
81
+ "default",
82
+ "byRole",
83
+ "preview"
84
+ ],
85
+ issues
86
+ });
87
+ if (chat === void 0) return null;
88
+ const fallback = "";
89
+ if (chat.default === void 0) issues.push({
90
+ message: "is required",
91
+ path: [...path, "default"]
92
+ });
93
+ const defaultModel = modelId({
94
+ value: chat.default,
95
+ fallback,
96
+ path: [...path, "default"],
97
+ issues
98
+ });
99
+ const byRole = section({
100
+ value: chat.byRole,
101
+ path: [...path, "byRole"],
102
+ allowed: Object.keys(isRecord(chat.byRole) ? chat.byRole : {}),
103
+ issues
104
+ });
105
+ const roles = Object.fromEntries(Object.entries(byRole ?? {}).map(([role, model]) => [role, modelId({
106
+ value: model,
107
+ fallback,
108
+ path: [
109
+ ...path,
110
+ "byRole",
111
+ role
112
+ ],
113
+ issues
114
+ })]));
115
+ const preview = chat.preview === void 0 ? void 0 : modelId({
116
+ value: chat.preview,
117
+ fallback,
118
+ path: [...path, "preview"],
119
+ issues
120
+ });
121
+ return {
122
+ default: defaultModel,
123
+ ...byRole === void 0 ? {} : { byRole: roles },
124
+ ...preview === void 0 ? {} : { preview }
125
+ };
126
+ }
75
127
  function validateConfig(input) {
76
128
  const issues = [];
77
129
  const root = section({
@@ -93,7 +145,11 @@ function validateConfig(input) {
93
145
  const models = section({
94
146
  value: root?.models,
95
147
  path: ["models"],
96
- allowed: ["stt", "decision"],
148
+ allowed: [
149
+ "chat",
150
+ "stt",
151
+ "decision"
152
+ ],
97
153
  issues
98
154
  });
99
155
  const ontology = section({
@@ -103,6 +159,7 @@ function validateConfig(input) {
103
159
  issues
104
160
  });
105
161
  const telegram = telegramSettings(channels?.telegram, issues);
162
+ const chat = chatModelChoice(models?.chat, issues);
106
163
  const stt = models?.stt === false ? false : modelId({
107
164
  value: models?.stt,
108
165
  fallback: DEFAULT_STT_MODEL,
@@ -124,6 +181,7 @@ function validateConfig(input) {
124
181
  return { value: {
125
182
  channels: { telegram },
126
183
  models: {
184
+ chat,
127
185
  stt,
128
186
  decision
129
187
  },
@@ -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 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 };
@@ -10,7 +10,7 @@ import { type Transcriber } from "./voice.js";
10
10
  * eve's Telegram `onMessage` hook, in the order `agent-design.md` gives:
11
11
  *
12
12
  * 1. The hard gate, with no model: bots, channel posts and senders without a live pairing never
13
- * start a turn. An unknown sender who writes to the bot privately gets a pairing link.
13
+ * start a turn. An unknown sender who writes to the bot privately gets a pairing button.
14
14
  * 2. Voice becomes text, so the router and the agent read what was said.
15
15
  * 3. The router answers or observes. An observed message goes to the chat's ring and stops here.
16
16
  * 4. An answered message dispatches as the paired member, with the ring ahead of it as context.
@@ -8,8 +8,15 @@ import { telegramPrincipal } from "./principal.mjs";
8
8
  import { observedChatKey, observedContext } from "./observed.mjs";
9
9
  import { isAddressed, routeMessage } from "./router.mjs";
10
10
  import { voiceNote, voiceOf, voiceText } from "./voice.mjs";
11
- const UNKNOWN_PRIVATE = "I don't know who you are yet. Open this link and sign in to connect your Telegram account; it works for ten minutes:";
11
+ 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
+ const UNKNOWN_IN_GROUP = "I don't know you yet. Message me privately to connect your account.";
12
13
  const REFUSED = "Your access to this agent was revoked, or your membership is inactive. Ask a workspace administrator.";
14
+ function buttonTo(text, url) {
15
+ return { inline_keyboard: [[{
16
+ text,
17
+ url
18
+ }]] };
19
+ }
13
20
  function display(user) {
14
21
  if (user.username !== void 0) return `@${user.username}`;
15
22
  const name = [user.firstName, user.lastName].filter((part) => part !== void 0 && part !== "").join(" ");
@@ -27,7 +34,10 @@ function observedMessage(message, text) {
27
34
  };
28
35
  }
29
36
  function unknownInGroup(bot) {
30
- return `I don't know who you are yet. Message me privately to connect your account.${bot?.username === void 0 ? "" : ` https://t.me/${bot.username}`}`;
37
+ return bot?.username === void 0 ? { text: UNKNOWN_IN_GROUP } : {
38
+ text: UNKNOWN_IN_GROUP,
39
+ reply_markup: buttonTo("Message me", `https://t.me/${bot.username}`)
40
+ };
31
41
  }
32
42
  async function answerUnpaired(input) {
33
43
  const { context, message, lookup, dependencies } = input;
@@ -45,7 +55,10 @@ async function answerUnpaired(input) {
45
55
  externalId: message.from.id,
46
56
  ...sender === void 0 ? {} : { display: sender }
47
57
  });
48
- await context.telegram.post(`${UNKNOWN_PRIVATE}\n${dependencies.connectUrl(pairing.code)}`);
58
+ await context.telegram.post({
59
+ text: UNKNOWN_PRIVATE,
60
+ reply_markup: buttonTo("Connect account", dependencies.connectUrl(pairing.code))
61
+ });
49
62
  }
50
63
  async function handleInboundMessage(input) {
51
64
  const { context, dependencies } = input;
@@ -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.1",
3
+ "version": "0.1.3",
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",