@segmentstream/agent 0.1.3 → 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.
@@ -10,6 +10,7 @@ import { decide } from "eve/ai";
10
10
  var telegram_default = segmentstreamTelegram({
11
11
  settings: () => segmentstream.config.channels.telegram,
12
12
  models: () => segmentstream.config.models,
13
+ sessions: () => segmentstream.config.sessions,
13
14
  decide: (options) => decide(options)
14
15
  });
15
16
  export { telegram_default as default };
@@ -49,6 +49,22 @@ export interface SegmentStreamAgentConfigInput {
49
49
  /** Whether the agent reaches the ontology over MCP. On by default. */
50
50
  readonly connection?: boolean;
51
51
  };
52
+ readonly sessions?: {
53
+ /**
54
+ * What a group session spans: `chat` (default) keeps one session per group chat and forum
55
+ * topic, `thread` keeps eve's one session per reply thread.
56
+ */
57
+ readonly scope?: string;
58
+ /**
59
+ * The local time, `HH:MM`, after which the first message of a chat compacts its session's
60
+ * history before the turn runs; `false` leaves compaction to eve's context-window check.
61
+ */
62
+ readonly compactAt?: string | boolean;
63
+ /** The IANA time zone `compactAt` is read in, such as `Europe/London`. */
64
+ readonly timezone?: string;
65
+ /** Hours of silence after which a chat's next message starts with an empty history. */
66
+ readonly idleResetHours?: number | boolean;
67
+ };
52
68
  }
53
69
  export interface TelegramSettings {
54
70
  readonly connection: string;
@@ -66,6 +82,17 @@ export interface ChatModelChoice {
66
82
  /** The model of preview deployments, so a preview never spends on a large model. */
67
83
  readonly preview?: string;
68
84
  }
85
+ export declare const SESSION_SCOPES: readonly ["chat", "thread"];
86
+ /** What one group session spans: the chat (and forum topic), or one reply thread. */
87
+ export type SessionScope = (typeof SESSION_SCOPES)[number];
88
+ /** How the Telegram adapter keeps sessions: their scope, the daily compaction and the idle reset. */
89
+ export interface SessionSettings {
90
+ readonly scope: SessionScope;
91
+ /** `HH:MM` in `timezone`, or `false`. */
92
+ readonly compactAt: string | false;
93
+ readonly timezone: string;
94
+ readonly idleResetHours: number | false;
95
+ }
69
96
  /** The validated configuration with defaults applied. It stays JSON-serializable. */
70
97
  export interface SegmentStreamAgentConfig {
71
98
  readonly channels: {
@@ -80,6 +107,7 @@ export interface SegmentStreamAgentConfig {
80
107
  readonly ontology: {
81
108
  readonly connection: boolean;
82
109
  };
110
+ readonly sessions: SessionSettings;
83
111
  }
84
112
  /** One validation failure, in Standard Schema's issue shape. */
85
113
  export interface ConfigIssue {
@@ -8,6 +8,7 @@ const CONNECTION_KEY = /^[a-z][a-z0-9_]{0,63}$/;
8
8
  const TELEGRAM_POLICIES = ["mention", "smart"];
9
9
  const DEFAULT_STT_MODEL = "spacexai/grok-stt";
10
10
  const DEFAULT_DECISION_MODEL = "typesafe-ai/jev";
11
+ const SESSION_SCOPES = ["chat", "thread"];
11
12
  var SegmentStreamAgentConfigError = class extends Error {
12
13
  issues;
13
14
  name = "SegmentStreamAgentConfigError";
@@ -124,6 +125,58 @@ function chatModelChoice(value, issues) {
124
125
  ...preview === void 0 ? {} : { preview }
125
126
  };
126
127
  }
128
+ const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
129
+ function isSessionScope(value) {
130
+ return SESSION_SCOPES.some((scope) => scope === value);
131
+ }
132
+ function isTimeZone(value) {
133
+ try {
134
+ new Intl.DateTimeFormat("en-US", { timeZone: value });
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+ function sessionSettings(value, issues) {
141
+ const path = ["sessions"];
142
+ const sessions = section({
143
+ value,
144
+ path,
145
+ allowed: [
146
+ "scope",
147
+ "compactAt",
148
+ "timezone",
149
+ "idleResetHours"
150
+ ],
151
+ issues
152
+ }) ?? {};
153
+ const scope = sessions.scope ?? "chat";
154
+ const compactAt = sessions.compactAt ?? "00:00";
155
+ const timezone = sessions.timezone ?? "UTC";
156
+ const idleResetHours = sessions.idleResetHours ?? false;
157
+ if (!isSessionScope(scope)) issues.push({
158
+ message: "must be \"chat\" or \"thread\"",
159
+ path: [...path, "scope"]
160
+ });
161
+ if (compactAt !== false && (typeof compactAt !== "string" || !TIME_OF_DAY.test(compactAt))) issues.push({
162
+ message: "must be a time such as 00:00, or false",
163
+ path: [...path, "compactAt"]
164
+ });
165
+ if (typeof timezone !== "string" || !isTimeZone(timezone)) issues.push({
166
+ message: "must be an IANA time zone such as Europe/London",
167
+ path: [...path, "timezone"]
168
+ });
169
+ if (idleResetHours !== false && (typeof idleResetHours !== "number" || !Number.isFinite(idleResetHours) || idleResetHours <= 0)) issues.push({
170
+ message: "must be a number of hours greater than zero, or false",
171
+ path: [...path, "idleResetHours"]
172
+ });
173
+ return {
174
+ scope: isSessionScope(scope) ? scope : "chat",
175
+ compactAt: typeof compactAt === "string" && TIME_OF_DAY.test(compactAt) ? compactAt : false,
176
+ timezone: typeof timezone === "string" ? timezone : "UTC",
177
+ idleResetHours: typeof idleResetHours === "number" && Number.isFinite(idleResetHours) && idleResetHours > 0 ? idleResetHours : false
178
+ };
179
+ }
127
180
  function validateConfig(input) {
128
181
  const issues = [];
129
182
  const root = section({
@@ -132,7 +185,8 @@ function validateConfig(input) {
132
185
  allowed: [
133
186
  "channels",
134
187
  "models",
135
- "ontology"
188
+ "ontology",
189
+ "sessions"
136
190
  ],
137
191
  issues
138
192
  });
@@ -173,6 +227,7 @@ function validateConfig(input) {
173
227
  issues
174
228
  });
175
229
  const connection = ontology?.connection ?? true;
230
+ const sessions = sessionSettings(root?.sessions, issues);
176
231
  if (typeof connection !== "boolean") issues.push({
177
232
  message: "must be true or false",
178
233
  path: ["ontology", "connection"]
@@ -185,7 +240,8 @@ function validateConfig(input) {
185
240
  stt,
186
241
  decision
187
242
  },
188
- ontology: { connection: connection === true }
243
+ ontology: { connection: connection === true },
244
+ sessions
189
245
  } };
190
246
  }
191
247
  const configSchema = { "~standard": {
@@ -199,4 +255,4 @@ function manifestOf(config) {
199
255
  if (validation.issues !== void 0) throw new SegmentStreamAgentConfigError(validation.issues);
200
256
  return validation.value;
201
257
  }
202
- export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, TELEGRAM_POLICIES, configSchema, manifestOf, validateConfig };
258
+ export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SESSION_SCOPES, SegmentStreamAgentConfigError, TELEGRAM_POLICIES, configSchema, manifestOf, validateConfig };
@@ -15,5 +15,5 @@ export declare const segmentstream: import("eve/extension").ExtensionHandle<{
15
15
  manifestOf: typeof manifestOf;
16
16
  };
17
17
  export default segmentstream;
18
- export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, manifestOf, type ChatModelChoice, type SegmentStreamAgentConfig, type SegmentStreamAgentConfigInput, type TelegramPolicy, type TelegramSettings, } from "./config.js";
18
+ export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, manifestOf, type ChatModelChoice, type SegmentStreamAgentConfig, type SegmentStreamAgentConfigInput, type SessionScope, type SessionSettings, type TelegramPolicy, type TelegramSettings, } from "./config.js";
19
19
  export { chooseModel, model, type DynamicModel, type ModelChoice } from "./model.js";
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream/agent",
3
- "version": "0.1.3",
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",