@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 +6 -7
- package/dist/extension/channels/telegram.mjs +1 -0
- package/dist/extension/config.d.ts +52 -0
- package/dist/extension/config.mjs +118 -4
- package/dist/extension/extension.d.ts +3 -3
- package/dist/extension/extension.mjs +1 -1
- package/dist/extension/lib/telegram/adapter.d.ts +6 -1
- package/dist/extension/lib/telegram/adapter.mjs +25 -1
- package/dist/extension/lib/telegram/inbound-request.d.ts +3 -0
- package/dist/extension/lib/telegram/inbound-request.mjs +1 -0
- package/dist/extension/lib/telegram/inbound.d.ts +8 -2
- package/dist/extension/lib/telegram/inbound.mjs +54 -5
- package/dist/extension/lib/telegram/sessions.d.ts +84 -0
- package/dist/extension/lib/telegram/sessions.mjs +200 -0
- package/dist/extension/model.d.ts +6 -13
- package/dist/extension/model.mjs +8 -4
- package/package.json +1 -1
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
|
|
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: [
|
|
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 {
|
|
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:
|
|
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>);
|
|
@@ -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.
|
|
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
|
|
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:
|
|
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: [
|
|
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
|
|
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;
|
package/dist/extension/model.mjs
CHANGED
|
@@ -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) =>
|
|
19
|
-
choice
|
|
20
|
-
|
|
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.
|
|
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",
|