@adriangalilea/utils 0.12.0 → 0.14.0

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
@@ -198,7 +198,7 @@ Every `SourcedError` carries `source`, `operation`, `status`, `context`, and the
198
198
  - **XDG**: XDG Base Directory paths — reads env vars set by [xdg-dirs](https://github.com/adriangalilea/xdg-dirs), falls back to spec defaults
199
199
  - **Unseen**: Persistent dedup filter — "what's new since last time?" for cron/monitoring workflows
200
200
  - **Project Discovery**: Find project/monorepo roots, detect JS/TS projects
201
- - **Bot plugins (GramIO)**: `kit` (graceful shutdown + admin context), `access-control` (gate + approve/deny menu, backed by sessions), `llm-stream` (streaming LLM markdown to Telegram with graceful degradation)
201
+ - **Bot plugins (GramIO)**: `kit` (graceful shutdown + admin context), `access-control` (gate + approve/deny menu, backed by sessions), `llm` (OpenAI-compat SSE parser `streamChat` + Telegram streaming output `ctx.startStream` + per-thread conversation history `ctx.llm`), `coalesce`, `language`, `menu`
202
202
 
203
203
  ### XDG Base Directories
204
204
 
@@ -305,7 +305,8 @@ Then `pnpm install`. Every `ctx.send` / `ctx.sendDocument` / `ctx.reply` / etc.
305
305
  | `@adriangalilea/utils/bot/kit` | `gracefulStart(bot)` — SIGINT/SIGTERM → `bot.stop()` → exit; force-kills if shutdown hangs.<br>`adminContext({ adminId? })` — reads `TELEGRAM_ADMIN_ID` from `kev` (with optional hardcoded fallback), decorates `ctx.adminId` + `ctx.isAdmin`. |
306
306
  | `@adriangalilea/utils/bot/access-control` | Personal-bot ACL — gates non-admin/non-default users; admin gets DM with `[✅ Aprobar][❌ Denegar]` on first attempt; `/access` opens a persistent menu (revoke / reapprove / list pending). Backed by `@gramio/session` per-user + a small index. |
307
307
  | `@adriangalilea/utils/bot/coalesce` | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate `message` updates with no marker. Middleware detects the burst and emits one combined event. |
308
- | `@adriangalilea/utils/bot/llm-stream` | `ctx.startStream()` for LLM token streams. Debounced `editMessageText`, splits at 4000 chars on paragraph/line/word boundary, parses Markdown locally so malformed mid-stream markup degrades to plain text instead of failing. |
308
+ | `@adriangalilea/utils/bot/llm` | The full LLM-chatbot pipeline in one module. **Input:** `streamChat(response)` parses OpenAI-compatible SSE (OpenAI, vllm, mlx-lm, llama.cpp, Together, Groq, …) into a typed `AsyncGenerator<{type: 'content' \| 'reasoning', text}>`. **Output:** `ctx.startStream()` (low-level: debounced markdown to Telegram, 4000-char split, exposes `wasPartial` after `.end()`). `ctx.startChatStream(response)` (high-level: consumes the stream, renders reasoning as a Telegram `expandable_blockquote` entity + content as streamed markdown — both go through `markdownToFormattable` with graceful degradation — returns `{ content, reasoning }`). **History:** `llmHistory({...})` returns `.plugin` (decorates `ctx.llm` with `.add() / .get() / .clear() / .all() / .clearAll()`, per-(user, thread) OpenAI `ChatMessage` shape, persisted in the shared session record so the menu's 🗑 Forget wipes it automatically) AND `.menuItem` (drop-in "🧹 Clear this thread" for `botMenu`). |
309
+ | `@adriangalilea/utils/bot/menu` | `botMenu({ command, description, items, privacy?, personalData?, adminContact })` — `/settings` command + InlineKeyboard router. With `personalData: { storage }`, auto-adds 🗑 Forget + 📥 Export buttons. `toggleMenuItem({ id, read, write, label: { off, on }, toast? })` — convenience factory for boolean-toggle items with dynamic label + optional toast, storage-agnostic via `read`/`write` closures. |
309
310
 
310
311
  Standard wiring:
311
312
 
@@ -314,18 +315,37 @@ import { Bot } from 'gramio'
314
315
  import { redisStorage } from '@gramio/storage-redis'
315
316
  import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
316
317
  import { accessControl } from '@adriangalilea/utils/bot/access-control'
317
- import { llmStream } from '@adriangalilea/utils/bot/llm-stream'
318
+ import { session } from '@gramio/session'
319
+ import { llmStream, llmHistory } from '@adriangalilea/utils/bot/llm'
318
320
 
319
321
  const storage = redisStorage() // ONE instance, shared
322
+ const userSession = session({ storage, key: 'session', initial: () => ({}) })
323
+ const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 })
320
324
 
321
325
  const bot = new Bot(process.env.BOT_TOKEN!)
322
326
  .extend(adminContext({ adminId: 190202471 })) // KEV.TELEGRAM_ADMIN_ID overrides
323
- .extend(accessControl({ storage, defaults: [] })) // gate; depends on adminContext
327
+ .extend(userSession)
328
+ .extend(accessControl({ session: userSession, storage, defaults: [] }))
324
329
  .extend(llmStream())
325
- .command('chat', async (ctx) => {
326
- const stream = ctx.startStream()
327
- for await (const chunk of yourLLM()) await stream.append(chunk.text)
328
- await stream.end()
330
+ .extend(chat.plugin)
331
+ .on('message', async (ctx) => {
332
+ if (!ctx.access.allowed) return
333
+ ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
334
+
335
+ // Any OpenAI-compatible endpoint: vllm-mlx, mlx-lm, llama.cpp, Together, Groq, OpenAI, …
336
+ const response = await fetch(process.env.LLM_URL!, {
337
+ method: 'POST',
338
+ headers: { 'Content-Type': 'application/json' },
339
+ body: JSON.stringify({
340
+ model: process.env.LLM_MODEL,
341
+ messages: [{ role: 'system', content: 'You are helpful.' }, ...ctx.llm.get()],
342
+ stream: true,
343
+ }),
344
+ })
345
+
346
+ // High-level: handles reasoning (collapsed blockquote) + content streaming.
347
+ const { content } = await ctx.startChatStream(response)
348
+ ctx.llm.add({ role: 'assistant', content })
329
349
  })
330
350
 
331
351
  await gracefulStart(bot)
@@ -25,7 +25,7 @@
25
25
  * storage[String(userId)] = {
26
26
  * access: { status, approvedAt, … }, // ← this plugin
27
27
  * language: 'es', // ← bot/language
28
- * history: { items: [...] }, // ← bot/message-history
28
+ * llm: { shards: { 'general': [...] } }, // ← bot/llm (history)
29
29
  * }
30
30
  *
31
31
  * Plus one tiny admin-side index so `/access` can list pending /
@@ -25,7 +25,7 @@
25
25
  * storage[String(userId)] = {
26
26
  * access: { status, approvedAt, … }, // ← this plugin
27
27
  * language: 'es', // ← bot/language
28
- * history: { items: [...] }, // ← bot/message-history
28
+ * llm: { shards: { 'general': [...] } }, // ← bot/llm (history)
29
29
  * }
30
30
  *
31
31
  * Plus one tiny admin-side index so `/access` can list pending /
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
8
8
  * import { accessControl } from '@adriangalilea/utils/bot/access-control'
9
- * import { llmStream } from '@adriangalilea/utils/bot/llm-stream'
9
+ * import { llmStream } from '@adriangalilea/utils/bot/llm'
10
10
  *
11
11
  * Or all-in-one (pulls every subpath):
12
12
  * import { ... } from '@adriangalilea/utils/bot'
@@ -14,8 +14,7 @@
14
14
  export * from './kit.js';
15
15
  export * from './access-control.js';
16
16
  export * from './coalesce.js';
17
- export * from './llm-stream.js';
17
+ export * from './llm.js';
18
18
  export * from './menu.js';
19
19
  export * from './language.js';
20
- export * from './message-history.js';
21
20
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,cAAc,UAAU,CAAA;AACxB,cAAc,qBAAqB,CAAA;AACnC,cAAc,eAAe,CAAA;AAC7B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,sBAAsB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,cAAc,UAAU,CAAA;AACxB,cAAc,qBAAqB,CAAA;AACnC,cAAc,eAAe,CAAA;AAC7B,cAAc,UAAU,CAAA;AACxB,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA"}
package/dist/bot/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
8
8
  * import { accessControl } from '@adriangalilea/utils/bot/access-control'
9
- * import { llmStream } from '@adriangalilea/utils/bot/llm-stream'
9
+ * import { llmStream } from '@adriangalilea/utils/bot/llm'
10
10
  *
11
11
  * Or all-in-one (pulls every subpath):
12
12
  * import { ... } from '@adriangalilea/utils/bot'
@@ -14,8 +14,7 @@
14
14
  export * from './kit.js';
15
15
  export * from './access-control.js';
16
16
  export * from './coalesce.js';
17
- export * from './llm-stream.js';
17
+ export * from './llm.js';
18
18
  export * from './menu.js';
19
19
  export * from './language.js';
20
- export * from './message-history.js';
21
20
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,cAAc,UAAU,CAAA;AACxB,cAAc,qBAAqB,CAAA;AACnC,cAAc,eAAe,CAAA;AAC7B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,sBAAsB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,cAAc,UAAU,CAAA;AACxB,cAAc,qBAAqB,CAAA;AACnC,cAAc,eAAe,CAAA;AAC7B,cAAc,UAAU,CAAA;AACxB,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA"}
@@ -0,0 +1,520 @@
1
+ /**
2
+ * GramIO LLM toolkit — three primitives that together form a complete
3
+ * Telegram LLM-chatbot pipeline:
4
+ *
5
+ * `streamChat(response)` — INPUT. Parses OpenAI-compatible SSE
6
+ * (OpenAI, vllm, mlx-lm, llama.cpp,
7
+ * Together, Groq, …) into a typed
8
+ * `AsyncGenerator` of `{type, text}` with
9
+ * `content` / `reasoning` separation.
10
+ *
11
+ * `ctx.startStream()` — OUTPUT (low-level). Debounced
12
+ * `editMessageText` to Telegram, local
13
+ * Markdown parse via `@gramio/format`,
14
+ * 4000-char split on paragraph / line /
15
+ * word boundary.
16
+ *
17
+ * `ctx.startChatStream()` — OUTPUT (high-level). Consumes a fetch
18
+ * `Response` (or any `AsyncIterable<LLMChunk>`)
19
+ * and renders BOTH phases of a thinking
20
+ * model: `reasoning` chunks go into one
21
+ * `<blockquote expandable>` message;
22
+ * `content` chunks go into a streamed
23
+ * markdown message below it. Returns
24
+ * `{ content, reasoning }` once the
25
+ * source generator ends.
26
+ *
27
+ * `ctx.llm.add / .get / …` — HISTORY. Per-(user, thread) conversation
28
+ * buffer in OpenAI `ChatMessage` shape.
29
+ * Persisted in the shared `@gramio/session`
30
+ * record under the `llm` field, so the
31
+ * `botMenu` 🗑 Forget button wipes it
32
+ * together with everything else (one
33
+ * record, one delete, no per-plugin
34
+ * registry).
35
+ *
36
+ * The trio composes:
37
+ *
38
+ * fetch(...) ──streamChat──> chunks ──ctx.startStream──> Telegram
39
+ * ▲ │
40
+ * └──── ctx.llm.get() ◀──── ctx.llm.add(assistant) ◀──┘
41
+ *
42
+ * Peer deps: `gramio`, `@gramio/session`, `@gramio/format`, `marked`.
43
+ *
44
+ * @example high-level — thinking model in ~10 LOC
45
+ * import { Bot } from 'gramio'
46
+ * import { session } from '@gramio/session'
47
+ * import { llmStream, llmHistory } from '@adriangalilea/utils/bot/llm'
48
+ *
49
+ * const userSession = session({ storage, key: 'session', initial: () => ({}) })
50
+ * const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 })
51
+ *
52
+ * const bot = new Bot(process.env.BOT_TOKEN!)
53
+ * .extend(userSession)
54
+ * .extend(llmStream())
55
+ * .extend(chat.plugin)
56
+ * .on('message', async (ctx) => {
57
+ * ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
58
+ * const response = await fetch(process.env.LLM_URL!, {
59
+ * method: 'POST',
60
+ * headers: { 'Content-Type': 'application/json' },
61
+ * body: JSON.stringify({
62
+ * model: process.env.LLM_MODEL,
63
+ * messages: [{ role: 'system', content: 'You are helpful.' }, ...ctx.llm.get()],
64
+ * stream: true,
65
+ * }),
66
+ * })
67
+ * const { content } = await ctx.startChatStream(response)
68
+ * ctx.llm.add({ role: 'assistant', content })
69
+ * })
70
+ *
71
+ * bot.start()
72
+ *
73
+ * @example low-level — manual loop when you need to map/filter chunks
74
+ * import { streamChat } from '@adriangalilea/utils/bot/llm'
75
+ *
76
+ * const stream = ctx.startStream()
77
+ * for await (const chunk of streamChat(response)) {
78
+ * if (chunk.type === 'content') await stream.append(chunk.text)
79
+ * // reasoning chunks dropped here
80
+ * }
81
+ * await stream.end()
82
+ */
83
+ import { type DeriveDefinitions, Plugin } from 'gramio';
84
+ import { session } from '@gramio/session';
85
+ import { type Polyglot } from '../say/index.js';
86
+ import type { MenuItem } from './menu.js';
87
+ /**
88
+ * A single chunk yielded by `streamChat`. Two kinds:
89
+ *
90
+ * - `content` — the visible reply text the user should see
91
+ * - `reasoning` — chain-of-thought / "thinking" text from reasoning
92
+ * models. Empty unless the model emits it.
93
+ *
94
+ * Most callers care about `content` only. Render `reasoning` separately
95
+ * (collapsed, italicized) if you want to surface thinking.
96
+ */
97
+ export type LLMChunk = {
98
+ type: 'content';
99
+ text: string;
100
+ } | {
101
+ type: 'reasoning';
102
+ text: string;
103
+ };
104
+ /**
105
+ * Parse an OpenAI-compatible chat-completions SSE response into a
106
+ * typed `AsyncGenerator<LLMChunk>`. Reads `response.body` once.
107
+ *
108
+ * Recognised reasoning aliases (as of 2026): `reasoning_content`
109
+ * (vllm, qwen3, DeepSeek-R1, gpt-oss harmony) and `reasoning` (some
110
+ * mlx-lm forks, gemma builds). If a model surfaces a new key, add it
111
+ * here — single source of truth for the field.
112
+ *
113
+ * Constrained-SSE assumption: lines are `\n`-delimited and each event
114
+ * is `data: <json>` or `data: [DONE]`. This matches every OpenAI-compat
115
+ * server in the wild but is NOT the full SSE spec (no comments, no
116
+ * multi-line `data:`, no `retry`/`id` fields). Swap to
117
+ * `eventsource-parser` if you hit a producer that needs them.
118
+ *
119
+ * Malformed JSON lines are silently skipped; the generator ends when
120
+ * the stream closes.
121
+ *
122
+ * @param response the `fetch` `Response` from a `stream: true` chat
123
+ * completion call. Must not be already consumed.
124
+ * @throws if `response.body` is null (non-streaming response).
125
+ *
126
+ * @example framework-agnostic — parser doesn't know about Telegram
127
+ * const res = await fetch(url, { method: 'POST', body })
128
+ * for await (const chunk of streamChat(res)) {
129
+ * if (chunk.type === 'content') process.stdout.write(chunk.text)
130
+ * }
131
+ */
132
+ export declare function streamChat(response: Response): AsyncGenerator<LLMChunk>;
133
+ export type StreamOptions = {
134
+ /** Debounce window between edits, in ms. Default 800. */
135
+ debounceMs?: number;
136
+ /** Initial placeholder shown until the first chunk arrives. Default "…". */
137
+ placeholder?: string;
138
+ /** Parse buffer as markdown. Default true. Set false for plain text streaming. */
139
+ markdown?: boolean;
140
+ /** Called on edit/send errors after internal recovery (rate limits, etc.). */
141
+ onError?: (err: unknown) => void;
142
+ };
143
+ export declare class MarkdownStreamer {
144
+ private buffer;
145
+ private currentMessageId?;
146
+ private firstSendPromise?;
147
+ private debounceTimer?;
148
+ private inFlight;
149
+ private dirty;
150
+ private ended;
151
+ /**
152
+ * `true` after `.end()` returns if the stream finished with un-flushed
153
+ * buffer or an outstanding error that the streamer couldn't recover
154
+ * from. Useful for partial-response diagnostics in catch blocks:
155
+ *
156
+ * try { for await (...) await stream.append(...) }
157
+ * finally {
158
+ * await stream.end()
159
+ * if (stream.wasPartial) logger.warn('LLM stream cut off')
160
+ * }
161
+ */
162
+ wasPartial: boolean;
163
+ private chatId;
164
+ private threadId?;
165
+ private bot;
166
+ private opts;
167
+ constructor(ctx: {
168
+ chat: {
169
+ id: number;
170
+ };
171
+ threadId?: number;
172
+ bot: MarkdownStreamer['bot'];
173
+ }, opts: StreamOptions);
174
+ /** Append a chunk. Schedules a debounced edit. */
175
+ append(text: string): Promise<void>;
176
+ /** Flush any pending edit and close the stream. Idempotent. */
177
+ end(): Promise<void>;
178
+ private scheduleFlush;
179
+ private flushNow;
180
+ }
181
+ /**
182
+ * GramIO plugin. Adds `ctx.startStream(opts?)` on every message context.
183
+ *
184
+ * Defaults set here apply to every stream; per-call options in
185
+ * `ctx.startStream({...})` override them.
186
+ */
187
+ export declare const llmStream: (defaults?: StreamOptions) => Plugin<{}, DeriveDefinitions & {
188
+ message: {
189
+ startStream: (opts?: StreamOptions) => MarkdownStreamer;
190
+ /**
191
+ * One-call helper for chat completions: consumes a `Response`
192
+ * (or any `AsyncIterable<LLMChunk>`), renders reasoning to an
193
+ * expandable blockquote message + content to a streamed markdown
194
+ * message, returns `{ content, reasoning }` when the stream ends.
195
+ */
196
+ startChatStream: (source: Response | AsyncIterable<LLMChunk>, opts?: ChatStreamOptions) => Promise<ChatStreamResult>;
197
+ };
198
+ }, {}>;
199
+ /**
200
+ * Return shape of `consumeChatStream` / `ctx.startChatStream`. Both
201
+ * full transcripts are returned so the caller decides what to persist
202
+ * (typically `content` to `ctx.llm`; `reasoning` already lives in the
203
+ * Telegram message and is rarely worth storing).
204
+ */
205
+ export type ChatStreamResult = {
206
+ /** Full assistant text concatenated from all `content` chunks. */
207
+ content: string;
208
+ /** Full reasoning text concatenated from all `reasoning` chunks. Empty for non-thinking models. */
209
+ reasoning: string;
210
+ };
211
+ export type ChatStreamOptions = StreamOptions;
212
+ /**
213
+ * One-shot helper that consumes an LLM stream and renders BOTH phases
214
+ * (reasoning, content) to Telegram with the canonical pattern:
215
+ *
216
+ * - reasoning chunks → one message containing a single
217
+ * `<blockquote expandable>…</blockquote>` (HTML), debounced edits,
218
+ * stays expanded after the stream ends so the user can review it
219
+ * collapsed/uncollapsed at will
220
+ * - content chunks → a fresh `MarkdownStreamer` message below the
221
+ * reasoning one (auto-threaded via SendMixin)
222
+ *
223
+ * Returns when the source generator ends. Equivalent to (and replaces)
224
+ * the ~50 LOC of bookkeeping every thinking-model bot would otherwise
225
+ * write by hand around `streamChat` + `ctx.startStream`.
226
+ *
227
+ * Accepts either a raw `fetch` `Response` (wrapped with `streamChat`
228
+ * internally) or an `AsyncIterable<LLMChunk>` (when you want to map /
229
+ * filter chunks before the stream hits Telegram).
230
+ *
231
+ * @example via ctx.startChatStream from the llmStream plugin
232
+ * const response = await fetch(LLM_URL, { method: 'POST', body })
233
+ * const { content } = await ctx.startChatStream(response)
234
+ * ctx.llm.add({ role: 'assistant', content })
235
+ *
236
+ * @example standalone, framework-agnostic
237
+ * const { content, reasoning } = await consumeChatStream(ctx, response)
238
+ */
239
+ export declare const consumeChatStream: (ctx: {
240
+ chat: {
241
+ id: number;
242
+ };
243
+ threadId?: number;
244
+ bot: MarkdownStreamer["bot"];
245
+ }, source: Response | AsyncIterable<LLMChunk>, opts?: ChatStreamOptions) => Promise<ChatStreamResult>;
246
+ /**
247
+ * Multimodal content shape from OpenAI's chat-completions spec. Either
248
+ * a plain string or an ordered array of typed parts. Image URLs cover
249
+ * both http(s) and Telegram `getFile` resolved paths.
250
+ */
251
+ export type ChatContent = string | Array<{
252
+ type: 'text';
253
+ text: string;
254
+ } | {
255
+ type: 'image_url';
256
+ image_url: {
257
+ url: string;
258
+ };
259
+ }>;
260
+ /**
261
+ * One turn in the conversation. The library does NOT filter by role —
262
+ * if you persist `system` turns, they ride along on every `get()`.
263
+ * Most callers prepend their system prompt fresh each request and only
264
+ * persist `user` / `assistant`.
265
+ */
266
+ export type ChatMessage = {
267
+ role: 'system' | 'user' | 'assistant' | 'tool';
268
+ content: ChatContent;
269
+ /** Unix seconds when added — used for retention pruning. */
270
+ date: number;
271
+ };
272
+ /** Per-thread shards of `ChatMessage`s, persisted in the session. */
273
+ type ChatRecord = {
274
+ shards: {
275
+ [threadKey: string]: ChatMessage[];
276
+ };
277
+ };
278
+ /** Loose session shape — this plugin only touches the `llm` field. */
279
+ type LLMSessionLike = {
280
+ llm?: ChatRecord;
281
+ };
282
+ /** @internal — kept unexported so it doesn't clash with peers' refs. */
283
+ type LLMSessionPluginRef = ReturnType<typeof session<LLMSessionLike, 'session'>>;
284
+ export type LLMHistoryOptions = {
285
+ /**
286
+ * Shared session plugin. This plugin extends it for type flow;
287
+ * gramio's runtime dedup ensures the session derive runs once.
288
+ */
289
+ session: LLMSessionPluginRef;
290
+ /** Ring buffer cap **per thread**. Oldest entries dropped past this. */
291
+ maxTurns: number;
292
+ /** Entries older than this (in days) are dropped on read. */
293
+ retentionDays: number;
294
+ /**
295
+ * Override the labels of the `menuItem` (the "🧹 clear this thread"
296
+ * button rendered inside a `botMenu`). Defaults are polyglot
297
+ * literals covering en + es.
298
+ */
299
+ menuLabels?: {
300
+ /** Button label. Default: `{ en: '🧹 Clear this thread', es: '🧹 Limpiar este hilo' }`. */
301
+ item?: Polyglot<string>;
302
+ /** Toast shown after a successful clear. Default: `{ en: '🧹 Cleared.', es: '🧹 Limpio.' }`. */
303
+ cleared?: Polyglot<string>;
304
+ };
305
+ };
306
+ export type LLMHistoryFeature = {
307
+ plugin: ReturnType<typeof buildHistoryPlugin>;
308
+ /**
309
+ * Drop-in `MenuItem` for `botMenu({ items: [...] })`: a "clear this
310
+ * thread" button that calls `ctx.llm.clear()` on the current
311
+ * (user, thread) shard and acknowledges with a toast. Sibling
312
+ * threads stay intact.
313
+ */
314
+ menuItem: MenuItem;
315
+ };
316
+ /**
317
+ * Methods decorated onto `ctx.llm`. All synchronous — reads/writes the
318
+ * session record via `@gramio/session`'s Proxy, which auto-persists.
319
+ *
320
+ * Thread isolation is automatic: every method operates on the shard
321
+ * for `ctx.threadId` (or `'general'` when no thread). Different threads
322
+ * = different conversations, no leakage.
323
+ */
324
+ export type LLMHistoryApi = {
325
+ /** Append one message to the CURRENT thread's shard. */
326
+ add: (message: Omit<ChatMessage, 'date'> & {
327
+ date?: number;
328
+ }) => void;
329
+ /** Pruned snapshot of the CURRENT thread, oldest-first. */
330
+ get: () => ReadonlyArray<ChatMessage>;
331
+ /** Wipe the CURRENT thread's shard. */
332
+ clear: () => void;
333
+ /**
334
+ * Full sharded map, pruned. Use for /export or admin views. Keys are
335
+ * thread ids (or `'general'`) → ordered messages.
336
+ */
337
+ all: () => Readonly<{
338
+ [threadKey: string]: ReadonlyArray<ChatMessage>;
339
+ }>;
340
+ /** Wipe ALL threads for this user. */
341
+ clearAll: () => void;
342
+ };
343
+ type LLMHistoryDerives = {
344
+ llm: LLMHistoryApi;
345
+ };
346
+ /**
347
+ * Per-(user, thread) LLM conversation history. Opt-in. Persists in the
348
+ * shared `@gramio/session` record under `llm`, so 🗑 Forget from
349
+ * `botMenu` wipes it together with everything else — one record, one
350
+ * delete, no per-plugin registry.
351
+ *
352
+ * @example
353
+ * const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 })
354
+ * bot.extend(chat.plugin)
355
+ * .on('message', (ctx) => {
356
+ * ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
357
+ * const messages = ctx.llm.get() // ChatMessage[] for current thread
358
+ * // ... call LLM with messages, then:
359
+ * ctx.llm.add({ role: 'assistant', content: reply })
360
+ * })
361
+ */
362
+ export declare const llmHistory: (opts: LLMHistoryOptions) => LLMHistoryFeature;
363
+ declare const buildHistoryPlugin: (args: {
364
+ sessionPlugin: LLMSessionPluginRef;
365
+ maxTurns: number;
366
+ retentionDays: number;
367
+ }) => Plugin<{}, DeriveDefinitions & {
368
+ global: LLMHistoryDerives;
369
+ } & {
370
+ message: {
371
+ session: LLMSessionLike & {
372
+ $clear: () => Promise<void>;
373
+ };
374
+ };
375
+ channel_post: {
376
+ session: LLMSessionLike & {
377
+ $clear: () => Promise<void>;
378
+ };
379
+ };
380
+ inline_query: {
381
+ session: LLMSessionLike & {
382
+ $clear: () => Promise<void>;
383
+ };
384
+ };
385
+ chosen_inline_result: {
386
+ session: LLMSessionLike & {
387
+ $clear: () => Promise<void>;
388
+ };
389
+ };
390
+ callback_query: {
391
+ session: LLMSessionLike & {
392
+ $clear: () => Promise<void>;
393
+ };
394
+ };
395
+ shipping_query: {
396
+ session: LLMSessionLike & {
397
+ $clear: () => Promise<void>;
398
+ };
399
+ };
400
+ pre_checkout_query: {
401
+ session: LLMSessionLike & {
402
+ $clear: () => Promise<void>;
403
+ };
404
+ };
405
+ poll_answer: {
406
+ session: LLMSessionLike & {
407
+ $clear: () => Promise<void>;
408
+ };
409
+ };
410
+ chat_join_request: {
411
+ session: LLMSessionLike & {
412
+ $clear: () => Promise<void>;
413
+ };
414
+ };
415
+ new_chat_members: {
416
+ session: LLMSessionLike & {
417
+ $clear: () => Promise<void>;
418
+ };
419
+ };
420
+ new_chat_title: {
421
+ session: LLMSessionLike & {
422
+ $clear: () => Promise<void>;
423
+ };
424
+ };
425
+ new_chat_photo: {
426
+ session: LLMSessionLike & {
427
+ $clear: () => Promise<void>;
428
+ };
429
+ };
430
+ delete_chat_photo: {
431
+ session: LLMSessionLike & {
432
+ $clear: () => Promise<void>;
433
+ };
434
+ };
435
+ group_chat_created: {
436
+ session: LLMSessionLike & {
437
+ $clear: () => Promise<void>;
438
+ };
439
+ };
440
+ message_auto_delete_timer_changed: {
441
+ session: LLMSessionLike & {
442
+ $clear: () => Promise<void>;
443
+ };
444
+ };
445
+ migrate_to_chat_id: {
446
+ session: LLMSessionLike & {
447
+ $clear: () => Promise<void>;
448
+ };
449
+ };
450
+ migrate_from_chat_id: {
451
+ session: LLMSessionLike & {
452
+ $clear: () => Promise<void>;
453
+ };
454
+ };
455
+ pinned_message: {
456
+ session: LLMSessionLike & {
457
+ $clear: () => Promise<void>;
458
+ };
459
+ };
460
+ invoice: {
461
+ session: LLMSessionLike & {
462
+ $clear: () => Promise<void>;
463
+ };
464
+ };
465
+ successful_payment: {
466
+ session: LLMSessionLike & {
467
+ $clear: () => Promise<void>;
468
+ };
469
+ };
470
+ chat_shared: {
471
+ session: LLMSessionLike & {
472
+ $clear: () => Promise<void>;
473
+ };
474
+ };
475
+ proximity_alert_triggered: {
476
+ session: LLMSessionLike & {
477
+ $clear: () => Promise<void>;
478
+ };
479
+ };
480
+ video_chat_scheduled: {
481
+ session: LLMSessionLike & {
482
+ $clear: () => Promise<void>;
483
+ };
484
+ };
485
+ video_chat_started: {
486
+ session: LLMSessionLike & {
487
+ $clear: () => Promise<void>;
488
+ };
489
+ };
490
+ video_chat_ended: {
491
+ session: LLMSessionLike & {
492
+ $clear: () => Promise<void>;
493
+ };
494
+ };
495
+ video_chat_participants_invited: {
496
+ session: LLMSessionLike & {
497
+ $clear: () => Promise<void>;
498
+ };
499
+ };
500
+ web_app_data: {
501
+ session: LLMSessionLike & {
502
+ $clear: () => Promise<void>;
503
+ };
504
+ };
505
+ location: {
506
+ session: LLMSessionLike & {
507
+ $clear: () => Promise<void>;
508
+ };
509
+ };
510
+ passport_data: {
511
+ session: LLMSessionLike & {
512
+ $clear: () => Promise<void>;
513
+ };
514
+ };
515
+ } & {
516
+ message: LLMHistoryDerives;
517
+ callback_query: LLMHistoryDerives;
518
+ }, {}>;
519
+ export {};
520
+ //# sourceMappingURL=llm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"llm.d.ts","sourceRoot":"","sources":["../../src/bot/llm.ts"],"names":[],"mappings":"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"}