@diegoaltoworks/chatter 0.59.0 → 0.60.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.
@@ -262,6 +262,75 @@ ${ctx.join("\n\n")}`
262
262
  return { system, messages, context: ctx };
263
263
  }
264
264
 
265
+ // src/history/compaction.ts
266
+ var DEFAULT_TIMEOUT_MS = 8e3;
267
+ var LOAD_ALL = Number.MAX_SAFE_INTEGER;
268
+ var SUMMARY_PREFIX = "Summary of earlier conversation: ";
269
+ function defaultSummarize(client, model) {
270
+ return async ({ turns }) => {
271
+ const transcript = turns.map((turn) => `${turn.role}: ${turn.content}`).join("\n");
272
+ const { content } = await answerOnce({
273
+ client,
274
+ system: "Summarize the conversation turns below into a short paragraph a reader would need to continue the conversation. Preserve names, facts, and decisions; drop pleasantries. Reply with the summary only, no preamble.",
275
+ messages: [{ role: "user", content: transcript }],
276
+ // No `answerFn`: compaction is an internal chatter operation, not a
277
+ // chat surface, so it must not conscript a caller's brain hook, which
278
+ // is shaped to answer the user, not to summarize on the engine's behalf.
279
+ mode: "public",
280
+ model
281
+ });
282
+ return content;
283
+ };
284
+ }
285
+ function createHistoryCompactor(deps, options) {
286
+ const { threshold, keep, logger } = options;
287
+ if (!Number.isInteger(threshold) || threshold < 1) {
288
+ throw new Error(`Invalid history compaction threshold: ${threshold}`);
289
+ }
290
+ if (!Number.isInteger(keep) || keep < 0 || keep >= threshold) {
291
+ throw new Error(`Invalid history compaction keep: ${keep}`);
292
+ }
293
+ const summarize = options.summarize ?? defaultSummarize(deps.client, options.model);
294
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
295
+ return {
296
+ async maybeCompact(store, conversationId) {
297
+ const turns = await store.load(conversationId, LOAD_ALL);
298
+ if (turns.length < threshold) return;
299
+ const older = turns.slice(0, turns.length - keep);
300
+ let summary;
301
+ let timer;
302
+ try {
303
+ summary = await Promise.race([
304
+ summarize({ conversationId, turns: older }),
305
+ new Promise((_, reject) => {
306
+ timer = setTimeout(() => reject(new Error("history compaction timed out")), timeoutMs);
307
+ })
308
+ ]);
309
+ } catch (error) {
310
+ logger?.error(
311
+ `history compaction summarize failed for "${conversationId}"; history left untouched`,
312
+ error
313
+ );
314
+ return;
315
+ } finally {
316
+ clearTimeout(timer);
317
+ }
318
+ const trimmed = summary.trim();
319
+ if (!trimmed) return;
320
+ const fresh = await store.load(conversationId, LOAD_ALL);
321
+ const recent = fresh.slice(older.length);
322
+ await store.clear(conversationId);
323
+ await store.append(conversationId, {
324
+ role: "assistant",
325
+ content: `${SUMMARY_PREFIX}${trimmed}`
326
+ });
327
+ for (const turn of recent) {
328
+ await store.append(conversationId, turn);
329
+ }
330
+ }
331
+ };
332
+ }
333
+
265
334
  // src/channels/pipeline.ts
266
335
  var DEFAULT_HISTORY_LIMIT = 20;
267
336
  var DEFAULT_GROUP_RATE_LIMIT = { max: 30, windowMs: 60 * 60 * 1e3 };
@@ -292,6 +361,10 @@ function createInboundPipeline(deps, config) {
292
361
  const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
293
362
  const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
294
363
  const mode = "public";
364
+ const compactor = config.history?.compaction ? createHistoryCompactor(
365
+ { client: deps.client },
366
+ { model: config.model, ...config.history.compaction }
367
+ ) : void 0;
295
368
  return async function handleInboundMessage(msg, turn) {
296
369
  const allowedChats = config.allowedChats ?? [];
297
370
  const action = decideChannelAction(msg, {
@@ -367,6 +440,14 @@ function createInboundPipeline(deps, config) {
367
440
  await config.history?.store.append(conversationId, { role: "assistant", content });
368
441
  }
369
442
  await turn.reply.sendAnswer(msg.chatId, content);
443
+ const history = config.history;
444
+ if (compactor && historyEnabled && history) {
445
+ try {
446
+ await compactor.maybeCompact(history.store, conversationId);
447
+ } catch (error) {
448
+ config.logger?.error(`history compaction failed for "${conversationId}"`, error);
449
+ }
450
+ }
370
451
  return { action: "reply", content };
371
452
  };
372
453
  }
@@ -229,6 +229,75 @@ ${ctx.join("\n\n")}`
229
229
  return { system, messages, context: ctx };
230
230
  }
231
231
 
232
+ // src/history/compaction.ts
233
+ var DEFAULT_TIMEOUT_MS = 8e3;
234
+ var LOAD_ALL = Number.MAX_SAFE_INTEGER;
235
+ var SUMMARY_PREFIX = "Summary of earlier conversation: ";
236
+ function defaultSummarize(client, model) {
237
+ return async ({ turns }) => {
238
+ const transcript = turns.map((turn) => `${turn.role}: ${turn.content}`).join("\n");
239
+ const { content } = await answerOnce({
240
+ client,
241
+ system: "Summarize the conversation turns below into a short paragraph a reader would need to continue the conversation. Preserve names, facts, and decisions; drop pleasantries. Reply with the summary only, no preamble.",
242
+ messages: [{ role: "user", content: transcript }],
243
+ // No `answerFn`: compaction is an internal chatter operation, not a
244
+ // chat surface, so it must not conscript a caller's brain hook, which
245
+ // is shaped to answer the user, not to summarize on the engine's behalf.
246
+ mode: "public",
247
+ model
248
+ });
249
+ return content;
250
+ };
251
+ }
252
+ function createHistoryCompactor(deps, options) {
253
+ const { threshold, keep, logger } = options;
254
+ if (!Number.isInteger(threshold) || threshold < 1) {
255
+ throw new Error(`Invalid history compaction threshold: ${threshold}`);
256
+ }
257
+ if (!Number.isInteger(keep) || keep < 0 || keep >= threshold) {
258
+ throw new Error(`Invalid history compaction keep: ${keep}`);
259
+ }
260
+ const summarize = options.summarize ?? defaultSummarize(deps.client, options.model);
261
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
262
+ return {
263
+ async maybeCompact(store, conversationId) {
264
+ const turns = await store.load(conversationId, LOAD_ALL);
265
+ if (turns.length < threshold) return;
266
+ const older = turns.slice(0, turns.length - keep);
267
+ let summary;
268
+ let timer;
269
+ try {
270
+ summary = await Promise.race([
271
+ summarize({ conversationId, turns: older }),
272
+ new Promise((_, reject) => {
273
+ timer = setTimeout(() => reject(new Error("history compaction timed out")), timeoutMs);
274
+ })
275
+ ]);
276
+ } catch (error) {
277
+ logger?.error(
278
+ `history compaction summarize failed for "${conversationId}"; history left untouched`,
279
+ error
280
+ );
281
+ return;
282
+ } finally {
283
+ clearTimeout(timer);
284
+ }
285
+ const trimmed = summary.trim();
286
+ if (!trimmed) return;
287
+ const fresh = await store.load(conversationId, LOAD_ALL);
288
+ const recent = fresh.slice(older.length);
289
+ await store.clear(conversationId);
290
+ await store.append(conversationId, {
291
+ role: "assistant",
292
+ content: `${SUMMARY_PREFIX}${trimmed}`
293
+ });
294
+ for (const turn of recent) {
295
+ await store.append(conversationId, turn);
296
+ }
297
+ }
298
+ };
299
+ }
300
+
232
301
  // src/channels/pipeline.ts
233
302
  var DEFAULT_HISTORY_LIMIT = 20;
234
303
  var DEFAULT_GROUP_RATE_LIMIT = { max: 30, windowMs: 60 * 60 * 1e3 };
@@ -259,6 +328,10 @@ function createInboundPipeline(deps, config) {
259
328
  const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
260
329
  const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
261
330
  const mode = "public";
331
+ const compactor = config.history?.compaction ? createHistoryCompactor(
332
+ { client: deps.client },
333
+ { model: config.model, ...config.history.compaction }
334
+ ) : void 0;
262
335
  return async function handleInboundMessage(msg, turn) {
263
336
  const allowedChats = config.allowedChats ?? [];
264
337
  const action = decideChannelAction(msg, {
@@ -334,6 +407,14 @@ function createInboundPipeline(deps, config) {
334
407
  await config.history?.store.append(conversationId, { role: "assistant", content });
335
408
  }
336
409
  await turn.reply.sendAnswer(msg.chatId, content);
410
+ const history = config.history;
411
+ if (compactor && historyEnabled && history) {
412
+ try {
413
+ await compactor.maybeCompact(history.store, conversationId);
414
+ } catch (error) {
415
+ config.logger?.error(`history compaction failed for "${conversationId}"`, error);
416
+ }
417
+ }
337
418
  return { action: "reply", content };
338
419
  };
339
420
  }
@@ -20,6 +20,7 @@ import type { Logger } from "../core/logger";
20
20
  import { type RerankContext, type RewriteQuery } from "../core/pipeline";
21
21
  import type { PromptLoader } from "../core/prompts";
22
22
  import type { VectorStore } from "../core/retrieval";
23
+ import type { HistoryCompactionOptions } from "../history/compaction";
23
24
  import type { HistoryStore } from "../history/types";
24
25
  import { type ChannelMessage } from "./gates";
25
26
  /** Where a pipeline turn's output goes. Distinct from `ChannelSender` (`./senders`) — a transport implementing this may thread/quote the chat answer without doing the same for a plain gate acknowledgement. */
@@ -69,6 +70,13 @@ export interface InboundPipelineConfig {
69
70
  * opted out. @default every sender is enabled
70
71
  */
71
72
  historyEnabledFor?: (sender: string) => boolean | Promise<boolean>;
73
+ /**
74
+ * Summarize-then-truncate compaction, checked right after each reply is
75
+ * appended. Off by default — a conversation's window grows unbounded
76
+ * (aside from the store's own `maxPerConversation`) until this is set.
77
+ * See `../history/compaction`.
78
+ */
79
+ compaction?: HistoryCompactionOptions;
72
80
  };
73
81
  /** Group chats eligible for a reply. Empty (default) = every group. Has no effect on DMs, which always reply. */
74
82
  allowedChats?: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/channels/pipeline.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/channels/pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AACjC,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAEtE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EACL,KAAK,cAAc,EAIpB,MAAM,SAAS,CAAC;AAEjB,iNAAiN;AACjN,MAAM,WAAW,kBAAkB;IACjC,4CAA4C;IAC5C,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,kFAAkF;IAClF,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,YAAY,CAAC;CACvB;AAED,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,0GAA0G;IAC1G,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,iHAAiH;IACjH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,kGAAkG;IAClG,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oGAAoG;IACpG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sGAAsG;IACtG,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE;QACtB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;KACd,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACvD,gFAAgF;IAChF,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,YAAY,CAAC;QACpB,uDAAuD;QACvD,KAAK,CAAC,EAAE,MAAM,CAAC;QACf;;;;;;WAMG;QACH,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;QACnE;;;;;WAKG;QACH,UAAU,CAAC,EAAE,wBAAwB,CAAC;KACvC,CAAC;IACF,iHAAiH;IACjH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gHAAgH;IAChH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,oFAAoF;IACpF,KAAK,EAAE,kBAAkB,CAAC;IAC1B;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IACnD,kEAAkE;IAClE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAC;CACpF;AAED,MAAM,MAAM,sBAAsB,GAC9B;IAAE,MAAM,EAAE,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,cAAc,GAAG,aAAa,CAAA;CAAE,GACzE;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC,2FAA2F;AAC3F,MAAM,MAAM,eAAe,GAAG,CAC5B,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,WAAW,KACd,OAAO,CAAC,sBAAsB,CAAC,CAAC;AAiCrC;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,qBAAqB,GAC5B,eAAe,CA0IjB"}
@@ -24,6 +24,7 @@ import type { AnswerFn, TransformReply } from "../../core/answer";
24
24
  import type { BucketsFor } from "../../core/buckets";
25
25
  import { type Logger } from "../../core/logger";
26
26
  import type { RerankContext, RewriteQuery } from "../../core/pipeline";
27
+ import type { HistoryCompactionOptions } from "../../history/compaction";
27
28
  import type { HistoryStore } from "../../history/types";
28
29
  import type { Channel } from "../index";
29
30
  import { type TelegramApi } from "./api";
@@ -61,6 +62,12 @@ export interface TelegramChannelConfig {
61
62
  * @default every sender is enabled
62
63
  */
63
64
  historyEnabledFor?: (sender: string) => boolean | Promise<boolean>;
65
+ /**
66
+ * Summarize-then-truncate compaction — see
67
+ * `InboundPipelineConfig.history.compaction` in `./channels`.
68
+ * @default off
69
+ */
70
+ compaction?: HistoryCompactionOptions;
64
71
  };
65
72
  muteRegex?: RegExp;
66
73
  unmuteRegex?: RegExp;
@@ -1 +1 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../../src/channels/telegram/channel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAuB,KAAK,MAAM,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAExC,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,OAAO,CAAC;AAQ5D,MAAM,WAAW,qBAAqB;IACpC,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC;IACjB,8GAA8G;IAC9G,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iHAAiH;IACjH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,0IAA0I;IAC1I,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,iJAAiJ;IACjJ,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,kIAAkI;IAClI,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kIAAkI;IAClI,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4GAA4G;IAC5G,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE;QACtB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;KACd,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACvD,kFAAkF;IAClF,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,YAAY,CAAC;QACpB,uDAAuD;QACvD,KAAK,CAAC,EAAE,MAAM,CAAC;QACf;;;;WAIG;QACH,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;KACpE,CAAC;IACF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gHAAgH;IAChH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,kBAAkB;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,0GAA0G;IAC1G,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iGAAiG;IACjG,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,mGAAmG;IACnG,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qJAAqJ;IACrJ,GAAG,CAAC,EAAE,WAAW,CAAC;IAClB,qEAAqE;IACrE,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,uGAAuG;IACvG,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AASD,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAuG5E"}
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../../src/channels/telegram/channel.ts"],"names":[],"mappings":"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"}
@@ -393,6 +393,75 @@ ${ctx.join("\n\n")}`
393
393
  return { system, messages, context: ctx };
394
394
  }
395
395
 
396
+ // src/history/compaction.ts
397
+ var DEFAULT_TIMEOUT_MS = 8e3;
398
+ var LOAD_ALL = Number.MAX_SAFE_INTEGER;
399
+ var SUMMARY_PREFIX = "Summary of earlier conversation: ";
400
+ function defaultSummarize(client, model) {
401
+ return async ({ turns }) => {
402
+ const transcript = turns.map((turn) => `${turn.role}: ${turn.content}`).join("\n");
403
+ const { content } = await answerOnce({
404
+ client,
405
+ system: "Summarize the conversation turns below into a short paragraph a reader would need to continue the conversation. Preserve names, facts, and decisions; drop pleasantries. Reply with the summary only, no preamble.",
406
+ messages: [{ role: "user", content: transcript }],
407
+ // No `answerFn`: compaction is an internal chatter operation, not a
408
+ // chat surface, so it must not conscript a caller's brain hook, which
409
+ // is shaped to answer the user, not to summarize on the engine's behalf.
410
+ mode: "public",
411
+ model
412
+ });
413
+ return content;
414
+ };
415
+ }
416
+ function createHistoryCompactor(deps, options) {
417
+ const { threshold, keep, logger } = options;
418
+ if (!Number.isInteger(threshold) || threshold < 1) {
419
+ throw new Error(`Invalid history compaction threshold: ${threshold}`);
420
+ }
421
+ if (!Number.isInteger(keep) || keep < 0 || keep >= threshold) {
422
+ throw new Error(`Invalid history compaction keep: ${keep}`);
423
+ }
424
+ const summarize = options.summarize ?? defaultSummarize(deps.client, options.model);
425
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
426
+ return {
427
+ async maybeCompact(store, conversationId) {
428
+ const turns = await store.load(conversationId, LOAD_ALL);
429
+ if (turns.length < threshold) return;
430
+ const older = turns.slice(0, turns.length - keep);
431
+ let summary;
432
+ let timer;
433
+ try {
434
+ summary = await Promise.race([
435
+ summarize({ conversationId, turns: older }),
436
+ new Promise((_, reject) => {
437
+ timer = setTimeout(() => reject(new Error("history compaction timed out")), timeoutMs);
438
+ })
439
+ ]);
440
+ } catch (error) {
441
+ logger?.error(
442
+ `history compaction summarize failed for "${conversationId}"; history left untouched`,
443
+ error
444
+ );
445
+ return;
446
+ } finally {
447
+ clearTimeout(timer);
448
+ }
449
+ const trimmed = summary.trim();
450
+ if (!trimmed) return;
451
+ const fresh = await store.load(conversationId, LOAD_ALL);
452
+ const recent = fresh.slice(older.length);
453
+ await store.clear(conversationId);
454
+ await store.append(conversationId, {
455
+ role: "assistant",
456
+ content: `${SUMMARY_PREFIX}${trimmed}`
457
+ });
458
+ for (const turn of recent) {
459
+ await store.append(conversationId, turn);
460
+ }
461
+ }
462
+ };
463
+ }
464
+
396
465
  // src/channels/gates.ts
397
466
  function matches(re, text) {
398
467
  return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).test(text);
@@ -473,6 +542,10 @@ function createInboundPipeline(deps, config) {
473
542
  const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
474
543
  const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
475
544
  const mode = "public";
545
+ const compactor = config.history?.compaction ? createHistoryCompactor(
546
+ { client: deps.client },
547
+ { model: config.model, ...config.history.compaction }
548
+ ) : void 0;
476
549
  return async function handleInboundMessage(msg, turn) {
477
550
  const allowedChats = config.allowedChats ?? [];
478
551
  const action = decideChannelAction(msg, {
@@ -548,6 +621,14 @@ function createInboundPipeline(deps, config) {
548
621
  await config.history?.store.append(conversationId, { role: "assistant", content });
549
622
  }
550
623
  await turn.reply.sendAnswer(msg.chatId, content);
624
+ const history = config.history;
625
+ if (compactor && historyEnabled && history) {
626
+ try {
627
+ await compactor.maybeCompact(history.store, conversationId);
628
+ } catch (error) {
629
+ config.logger?.error(`history compaction failed for "${conversationId}"`, error);
630
+ }
631
+ }
551
632
  return { action: "reply", content };
552
633
  };
553
634
  }
@@ -348,6 +348,75 @@ ${ctx.join("\n\n")}`
348
348
  return { system, messages, context: ctx };
349
349
  }
350
350
 
351
+ // src/history/compaction.ts
352
+ var DEFAULT_TIMEOUT_MS = 8e3;
353
+ var LOAD_ALL = Number.MAX_SAFE_INTEGER;
354
+ var SUMMARY_PREFIX = "Summary of earlier conversation: ";
355
+ function defaultSummarize(client, model) {
356
+ return async ({ turns }) => {
357
+ const transcript = turns.map((turn) => `${turn.role}: ${turn.content}`).join("\n");
358
+ const { content } = await answerOnce({
359
+ client,
360
+ system: "Summarize the conversation turns below into a short paragraph a reader would need to continue the conversation. Preserve names, facts, and decisions; drop pleasantries. Reply with the summary only, no preamble.",
361
+ messages: [{ role: "user", content: transcript }],
362
+ // No `answerFn`: compaction is an internal chatter operation, not a
363
+ // chat surface, so it must not conscript a caller's brain hook, which
364
+ // is shaped to answer the user, not to summarize on the engine's behalf.
365
+ mode: "public",
366
+ model
367
+ });
368
+ return content;
369
+ };
370
+ }
371
+ function createHistoryCompactor(deps, options) {
372
+ const { threshold, keep, logger } = options;
373
+ if (!Number.isInteger(threshold) || threshold < 1) {
374
+ throw new Error(`Invalid history compaction threshold: ${threshold}`);
375
+ }
376
+ if (!Number.isInteger(keep) || keep < 0 || keep >= threshold) {
377
+ throw new Error(`Invalid history compaction keep: ${keep}`);
378
+ }
379
+ const summarize = options.summarize ?? defaultSummarize(deps.client, options.model);
380
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
381
+ return {
382
+ async maybeCompact(store, conversationId) {
383
+ const turns = await store.load(conversationId, LOAD_ALL);
384
+ if (turns.length < threshold) return;
385
+ const older = turns.slice(0, turns.length - keep);
386
+ let summary;
387
+ let timer;
388
+ try {
389
+ summary = await Promise.race([
390
+ summarize({ conversationId, turns: older }),
391
+ new Promise((_, reject) => {
392
+ timer = setTimeout(() => reject(new Error("history compaction timed out")), timeoutMs);
393
+ })
394
+ ]);
395
+ } catch (error) {
396
+ logger?.error(
397
+ `history compaction summarize failed for "${conversationId}"; history left untouched`,
398
+ error
399
+ );
400
+ return;
401
+ } finally {
402
+ clearTimeout(timer);
403
+ }
404
+ const trimmed = summary.trim();
405
+ if (!trimmed) return;
406
+ const fresh = await store.load(conversationId, LOAD_ALL);
407
+ const recent = fresh.slice(older.length);
408
+ await store.clear(conversationId);
409
+ await store.append(conversationId, {
410
+ role: "assistant",
411
+ content: `${SUMMARY_PREFIX}${trimmed}`
412
+ });
413
+ for (const turn of recent) {
414
+ await store.append(conversationId, turn);
415
+ }
416
+ }
417
+ };
418
+ }
419
+
351
420
  // src/channels/gates.ts
352
421
  function matches(re, text) {
353
422
  return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).test(text);
@@ -428,6 +497,10 @@ function createInboundPipeline(deps, config) {
428
497
  const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
429
498
  const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
430
499
  const mode = "public";
500
+ const compactor = config.history?.compaction ? createHistoryCompactor(
501
+ { client: deps.client },
502
+ { model: config.model, ...config.history.compaction }
503
+ ) : void 0;
431
504
  return async function handleInboundMessage(msg, turn) {
432
505
  const allowedChats = config.allowedChats ?? [];
433
506
  const action = decideChannelAction(msg, {
@@ -503,6 +576,14 @@ function createInboundPipeline(deps, config) {
503
576
  await config.history?.store.append(conversationId, { role: "assistant", content });
504
577
  }
505
578
  await turn.reply.sendAnswer(msg.chatId, content);
579
+ const history = config.history;
580
+ if (compactor && historyEnabled && history) {
581
+ try {
582
+ await compactor.maybeCompact(history.store, conversationId);
583
+ } catch (error) {
584
+ config.logger?.error(`history compaction failed for "${conversationId}"`, error);
585
+ }
586
+ }
506
587
  return { action: "reply", content };
507
588
  };
508
589
  }
@@ -23,6 +23,7 @@ import type { AnswerFn, TransformReply } from "../../core/answer";
23
23
  import type { BucketsFor } from "../../core/buckets";
24
24
  import { type Logger } from "../../core/logger";
25
25
  import type { RerankContext, RewriteQuery } from "../../core/pipeline";
26
+ import type { HistoryCompactionOptions } from "../../history/compaction";
26
27
  import type { HistoryStore } from "../../history/types";
27
28
  import type { CustomRoutes } from "../../types";
28
29
  import { type TelegramApi } from "./api";
@@ -62,6 +63,12 @@ export interface TelegramWebhookConfig {
62
63
  /** Most recent turns to load per reply. @default 20 */
63
64
  limit?: number;
64
65
  historyEnabledFor?: (sender: string) => boolean | Promise<boolean>;
66
+ /**
67
+ * Summarize-then-truncate compaction — see
68
+ * `InboundPipelineConfig.history.compaction` in `./channels`.
69
+ * @default off
70
+ */
71
+ compaction?: HistoryCompactionOptions;
65
72
  };
66
73
  muteRegex?: RegExp;
67
74
  unmuteRegex?: RegExp;
@@ -1 +1 @@
1
- {"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../../src/channels/telegram/webhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAuB,KAAK,MAAM,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EAAqB,KAAK,WAAW,EAAuB,MAAM,OAAO,CAAC;AAMjF,MAAM,WAAW,qBAAqB;IACpC,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;OAMG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8GAA8G;IAC9G,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iHAAiH;IACjH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kIAAkI;IAClI,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4GAA4G;IAC5G,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE;QACtB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;KACd,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACvD,kFAAkF;IAClF,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,YAAY,CAAC;QACpB,uDAAuD;QACvD,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;KACpE,CAAC;IACF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,mGAAmG;IACnG,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qJAAqJ;IACrJ,GAAG,CAAC,EAAE,WAAW,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAeD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,qBAAqB,GAAG,YAAY,CAkHtF"}
1
+ {"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../../src/channels/telegram/webhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAuB,KAAK,MAAM,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EAAqB,KAAK,WAAW,EAAuB,MAAM,OAAO,CAAC;AAMjF,MAAM,WAAW,qBAAqB;IACpC,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;OAMG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8GAA8G;IAC9G,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iHAAiH;IACjH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kIAAkI;IAClI,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4GAA4G;IAC5G,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE;QACtB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;KACd,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACvD,kFAAkF;IAClF,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,YAAY,CAAC;QACpB,uDAAuD;QACvD,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;QACnE;;;;WAIG;QACH,UAAU,CAAC,EAAE,wBAAwB,CAAC;KACvC,CAAC;IACF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,mGAAmG;IACnG,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qJAAqJ;IACrJ,GAAG,CAAC,EAAE,WAAW,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAeD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,qBAAqB,GAAG,YAAY,CAkHtF"}
@@ -54,6 +54,7 @@ import { type Logger } from "../../core/logger";
54
54
  import type { RerankContext, RewriteQuery } from "../../core/pipeline";
55
55
  import type { PromptLoader } from "../../core/prompts";
56
56
  import type { VectorStore } from "../../core/retrieval";
57
+ import type { HistoryCompactionOptions } from "../../history/compaction";
57
58
  import type { HistoryStore } from "../../history/types";
58
59
  import { type ChannelMessage, type SessionIdentityRegistry } from "../gates";
59
60
  import type { WhatsAppMessageEvent } from "./channel";
@@ -199,6 +200,12 @@ export interface WhatsAppInboundConfig {
199
200
  * @default every sender is enabled
200
201
  */
201
202
  historyEnabledFor?: (sender: string) => boolean | Promise<boolean>;
203
+ /**
204
+ * Summarize-then-truncate compaction — see
205
+ * `InboundPipelineConfig.history.compaction` in `./channels`.
206
+ * @default off
207
+ */
208
+ compaction?: HistoryCompactionOptions;
202
209
  };
203
210
  now?: () => number;
204
211
  /** Logger for gate/dispatch diagnostics. Default: a console logger writing to stderr. */
@@ -1 +1 @@
1
- {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/inbound.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnE,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AACjC,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAuB,KAAK,MAAM,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EACL,KAAK,cAAc,EAGnB,KAAK,uBAAuB,EAC7B,MAAM,UAAU,CAAC;AAElB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC;AACtD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAIrD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE/C;AAOD,kGAAkG;AAClG,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAG/E;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGpD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,QAAQ,EACd,OAAO,EAAE,SAAS,EAClB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,CA+BjB;AAID,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,GAAG,gBAAgB,CAWnE;AAED,mHAAmH;AACnH,wBAAgB,WAAW,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAWtD;AAeD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAiBvE;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,cAAc,CAAC;IACpB,8FAA8F;IAC9F,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,oBAAoB,EAC3B,QAAQ,EAAE,uBAAuB,GAChC,iBAAiB,CA+CnB;AAID,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,YAAY,CAAC;IACtB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,0GAA0G;IAC1G,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,iHAAiH;IACjH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,kGAAkG;IAClG,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,QAAQ,EAAE,uBAAuB,CAAC;IAClC,4HAA4H;IAC5H,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gHAAgH;IAChH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,oGAAoG;IACpG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE;QACtB,WAAW,EAAE,MAAM,CAAC;QACpB,IAAI,EAAE,MAAM,CAAC;KACd,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACvD;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B;;;;;OAKG;IACH,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,YAAY,CAAC;QACpB,uDAAuD;QACvD,KAAK,CAAC,EAAE,MAAM,CAAC;QACf;;;;WAIG;QACH,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;KACpE,CAAC;IACF,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,yFAAyF;IACzF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,uTAAuT;AACvT,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,qBAAqB,GAC5B,CAAC,KAAK,EAAE,oBAAoB,KAAK,OAAO,CAAC,IAAI,CAAC,CAmGhD"}
1
+ {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/inbound.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnE,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AACjC,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAuB,KAAK,MAAM,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EACL,KAAK,cAAc,EAGnB,KAAK,uBAAuB,EAC7B,MAAM,UAAU,CAAC;AAElB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC;AACtD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAIrD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE/C;AAOD,kGAAkG;AAClG,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAG/E;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGpD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,QAAQ,EACd,OAAO,EAAE,SAAS,EAClB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,CA+BjB;AAID,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,GAAG,gBAAgB,CAWnE;AAED,mHAAmH;AACnH,wBAAgB,WAAW,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAWtD;AAeD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAiBvE;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,cAAc,CAAC;IACpB,8FAA8F;IAC9F,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,oBAAoB,EAC3B,QAAQ,EAAE,uBAAuB,GAChC,iBAAiB,CA+CnB;AAID,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,YAAY,CAAC;IACtB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,0GAA0G;IAC1G,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,iHAAiH;IACjH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,kGAAkG;IAClG,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,QAAQ,EAAE,uBAAuB,CAAC;IAClC,4HAA4H;IAC5H,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gHAAgH;IAChH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,oGAAoG;IACpG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE;QACtB,WAAW,EAAE,MAAM,CAAC;QACpB,IAAI,EAAE,MAAM,CAAC;KACd,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACvD;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B;;;;;OAKG;IACH,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,YAAY,CAAC;QACpB,uDAAuD;QACvD,KAAK,CAAC,EAAE,MAAM,CAAC;QACf;;;;WAIG;QACH,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;QACnE;;;;WAIG;QACH,UAAU,CAAC,EAAE,wBAAwB,CAAC;KACvC,CAAC;IACF,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,yFAAyF;IACzF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,uTAAuT;AACvT,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,qBAAqB,GAC5B,CAAC,KAAK,EAAE,oBAAoB,KAAK,OAAO,CAAC,IAAI,CAAC,CAmGhD"}
@@ -840,6 +840,75 @@ ${ctx.join("\n\n")}`
840
840
  return { system, messages, context: ctx };
841
841
  }
842
842
 
843
+ // src/history/compaction.ts
844
+ var DEFAULT_TIMEOUT_MS = 8e3;
845
+ var LOAD_ALL = Number.MAX_SAFE_INTEGER;
846
+ var SUMMARY_PREFIX = "Summary of earlier conversation: ";
847
+ function defaultSummarize(client, model) {
848
+ return async ({ turns }) => {
849
+ const transcript = turns.map((turn) => `${turn.role}: ${turn.content}`).join("\n");
850
+ const { content } = await answerOnce({
851
+ client,
852
+ system: "Summarize the conversation turns below into a short paragraph a reader would need to continue the conversation. Preserve names, facts, and decisions; drop pleasantries. Reply with the summary only, no preamble.",
853
+ messages: [{ role: "user", content: transcript }],
854
+ // No `answerFn`: compaction is an internal chatter operation, not a
855
+ // chat surface, so it must not conscript a caller's brain hook, which
856
+ // is shaped to answer the user, not to summarize on the engine's behalf.
857
+ mode: "public",
858
+ model
859
+ });
860
+ return content;
861
+ };
862
+ }
863
+ function createHistoryCompactor(deps, options) {
864
+ const { threshold, keep, logger } = options;
865
+ if (!Number.isInteger(threshold) || threshold < 1) {
866
+ throw new Error(`Invalid history compaction threshold: ${threshold}`);
867
+ }
868
+ if (!Number.isInteger(keep) || keep < 0 || keep >= threshold) {
869
+ throw new Error(`Invalid history compaction keep: ${keep}`);
870
+ }
871
+ const summarize = options.summarize ?? defaultSummarize(deps.client, options.model);
872
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
873
+ return {
874
+ async maybeCompact(store, conversationId) {
875
+ const turns = await store.load(conversationId, LOAD_ALL);
876
+ if (turns.length < threshold) return;
877
+ const older = turns.slice(0, turns.length - keep);
878
+ let summary;
879
+ let timer;
880
+ try {
881
+ summary = await Promise.race([
882
+ summarize({ conversationId, turns: older }),
883
+ new Promise((_, reject) => {
884
+ timer = setTimeout(() => reject(new Error("history compaction timed out")), timeoutMs);
885
+ })
886
+ ]);
887
+ } catch (error) {
888
+ logger?.error(
889
+ `history compaction summarize failed for "${conversationId}"; history left untouched`,
890
+ error
891
+ );
892
+ return;
893
+ } finally {
894
+ clearTimeout(timer);
895
+ }
896
+ const trimmed = summary.trim();
897
+ if (!trimmed) return;
898
+ const fresh = await store.load(conversationId, LOAD_ALL);
899
+ const recent = fresh.slice(older.length);
900
+ await store.clear(conversationId);
901
+ await store.append(conversationId, {
902
+ role: "assistant",
903
+ content: `${SUMMARY_PREFIX}${trimmed}`
904
+ });
905
+ for (const turn of recent) {
906
+ await store.append(conversationId, turn);
907
+ }
908
+ }
909
+ };
910
+ }
911
+
843
912
  // src/channels/pipeline.ts
844
913
  var DEFAULT_HISTORY_LIMIT = 20;
845
914
  var DEFAULT_GROUP_RATE_LIMIT = { max: 30, windowMs: 60 * 60 * 1e3 };
@@ -870,6 +939,10 @@ function createInboundPipeline(deps, config) {
870
939
  const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
871
940
  const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
872
941
  const mode = "public";
942
+ const compactor = config.history?.compaction ? createHistoryCompactor(
943
+ { client: deps.client },
944
+ { model: config.model, ...config.history.compaction }
945
+ ) : void 0;
873
946
  return async function handleInboundMessage(msg, turn) {
874
947
  const allowedChats = config.allowedChats ?? [];
875
948
  const action = decideChannelAction(msg, {
@@ -945,6 +1018,14 @@ function createInboundPipeline(deps, config) {
945
1018
  await config.history?.store.append(conversationId, { role: "assistant", content });
946
1019
  }
947
1020
  await turn.reply.sendAnswer(msg.chatId, content);
1021
+ const history = config.history;
1022
+ if (compactor && historyEnabled && history) {
1023
+ try {
1024
+ await compactor.maybeCompact(history.store, conversationId);
1025
+ } catch (error) {
1026
+ config.logger?.error(`history compaction failed for "${conversationId}"`, error);
1027
+ }
1028
+ }
948
1029
  return { action: "reply", content };
949
1030
  };
950
1031
  }
@@ -778,6 +778,75 @@ ${ctx.join("\n\n")}`
778
778
  return { system, messages, context: ctx };
779
779
  }
780
780
 
781
+ // src/history/compaction.ts
782
+ var DEFAULT_TIMEOUT_MS = 8e3;
783
+ var LOAD_ALL = Number.MAX_SAFE_INTEGER;
784
+ var SUMMARY_PREFIX = "Summary of earlier conversation: ";
785
+ function defaultSummarize(client, model) {
786
+ return async ({ turns }) => {
787
+ const transcript = turns.map((turn) => `${turn.role}: ${turn.content}`).join("\n");
788
+ const { content } = await answerOnce({
789
+ client,
790
+ system: "Summarize the conversation turns below into a short paragraph a reader would need to continue the conversation. Preserve names, facts, and decisions; drop pleasantries. Reply with the summary only, no preamble.",
791
+ messages: [{ role: "user", content: transcript }],
792
+ // No `answerFn`: compaction is an internal chatter operation, not a
793
+ // chat surface, so it must not conscript a caller's brain hook, which
794
+ // is shaped to answer the user, not to summarize on the engine's behalf.
795
+ mode: "public",
796
+ model
797
+ });
798
+ return content;
799
+ };
800
+ }
801
+ function createHistoryCompactor(deps, options) {
802
+ const { threshold, keep, logger } = options;
803
+ if (!Number.isInteger(threshold) || threshold < 1) {
804
+ throw new Error(`Invalid history compaction threshold: ${threshold}`);
805
+ }
806
+ if (!Number.isInteger(keep) || keep < 0 || keep >= threshold) {
807
+ throw new Error(`Invalid history compaction keep: ${keep}`);
808
+ }
809
+ const summarize = options.summarize ?? defaultSummarize(deps.client, options.model);
810
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
811
+ return {
812
+ async maybeCompact(store, conversationId) {
813
+ const turns = await store.load(conversationId, LOAD_ALL);
814
+ if (turns.length < threshold) return;
815
+ const older = turns.slice(0, turns.length - keep);
816
+ let summary;
817
+ let timer;
818
+ try {
819
+ summary = await Promise.race([
820
+ summarize({ conversationId, turns: older }),
821
+ new Promise((_, reject) => {
822
+ timer = setTimeout(() => reject(new Error("history compaction timed out")), timeoutMs);
823
+ })
824
+ ]);
825
+ } catch (error) {
826
+ logger?.error(
827
+ `history compaction summarize failed for "${conversationId}"; history left untouched`,
828
+ error
829
+ );
830
+ return;
831
+ } finally {
832
+ clearTimeout(timer);
833
+ }
834
+ const trimmed = summary.trim();
835
+ if (!trimmed) return;
836
+ const fresh = await store.load(conversationId, LOAD_ALL);
837
+ const recent = fresh.slice(older.length);
838
+ await store.clear(conversationId);
839
+ await store.append(conversationId, {
840
+ role: "assistant",
841
+ content: `${SUMMARY_PREFIX}${trimmed}`
842
+ });
843
+ for (const turn of recent) {
844
+ await store.append(conversationId, turn);
845
+ }
846
+ }
847
+ };
848
+ }
849
+
781
850
  // src/channels/pipeline.ts
782
851
  var DEFAULT_HISTORY_LIMIT = 20;
783
852
  var DEFAULT_GROUP_RATE_LIMIT = { max: 30, windowMs: 60 * 60 * 1e3 };
@@ -808,6 +877,10 @@ function createInboundPipeline(deps, config) {
808
877
  const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
809
878
  const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
810
879
  const mode = "public";
880
+ const compactor = config.history?.compaction ? createHistoryCompactor(
881
+ { client: deps.client },
882
+ { model: config.model, ...config.history.compaction }
883
+ ) : void 0;
811
884
  return async function handleInboundMessage(msg, turn) {
812
885
  const allowedChats = config.allowedChats ?? [];
813
886
  const action = decideChannelAction(msg, {
@@ -883,6 +956,14 @@ function createInboundPipeline(deps, config) {
883
956
  await config.history?.store.append(conversationId, { role: "assistant", content });
884
957
  }
885
958
  await turn.reply.sendAnswer(msg.chatId, content);
959
+ const history = config.history;
960
+ if (compactor && historyEnabled && history) {
961
+ try {
962
+ await compactor.maybeCompact(history.store, conversationId);
963
+ } catch (error) {
964
+ config.logger?.error(`history compaction failed for "${conversationId}"`, error);
965
+ }
966
+ }
886
967
  return { action: "reply", content };
887
968
  };
888
969
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Summarize-then-truncate compaction for `HistoryStore` — folds older turns
3
+ * into a single stored summary row once a conversation's window grows past a
4
+ * threshold, so a long-running conversation's prompt doesn't grow without
5
+ * bound. Off by default; a caller wires `maybeCompact` in right after its own
6
+ * `store.append` call, the same way `./index`'s docs describe wiring the
7
+ * store itself.
8
+ *
9
+ * The summarize step races a timeout the same way `../images/captionComposer`
10
+ * races its compose step, except the safe fallback here is "leave history
11
+ * untouched" rather than a template pool — a stuck or failing summarize call
12
+ * must never lose turns nobody got to read back.
13
+ */
14
+ import type OpenAI from "openai";
15
+ import type { Logger } from "../core/logger";
16
+ import type { HistoryMessage, HistoryStore } from "./types";
17
+ /** What a summarize step receives: the turns being folded away, oldest first. Never includes the turns `keep` retains verbatim. */
18
+ export interface SummarizeRequest {
19
+ conversationId: string;
20
+ turns: HistoryMessage[];
21
+ }
22
+ /** Best-effort compaction step, typically an LLM call. */
23
+ export type SummarizeFn = (request: SummarizeRequest) => Promise<string>;
24
+ export interface HistoryCompactionOptions {
25
+ /** Turn count that triggers compaction. */
26
+ threshold: number;
27
+ /** Most recent turns kept verbatim after compaction; everything older is folded into the summary. Must be less than `threshold`. */
28
+ keep: number;
29
+ /**
30
+ * Best-effort summarize step. Defaults to `answerOnce` with a neutral
31
+ * prompt and no `answerFn` (so it always uses the built-in completion,
32
+ * never a caller's brain hook) — set this to route summarization through a
33
+ * caller's own `answerFn`, a cheaper model, or a non-LLM summarizer.
34
+ */
35
+ summarize?: SummarizeFn;
36
+ /** Model for the default `summarize` step. Ignored when `summarize` is supplied. Falls back to `answerOnce`'s own default model when unset. */
37
+ model?: string;
38
+ /** Milliseconds before `summarize` is abandoned in favour of leaving history untouched for this turn. @default 8000 */
39
+ timeoutMs?: number;
40
+ logger?: Pick<Logger, "error">;
41
+ }
42
+ export interface HistoryCompactor {
43
+ /**
44
+ * Compacts `conversationId` if its stored turn count has reached
45
+ * `threshold`. A no-op below threshold. A failed or timed-out `summarize`
46
+ * leaves the store exactly as it was and does not throw; a failure in the
47
+ * store itself (`load`/`clear`/`append`) propagates like any other store
48
+ * failure.
49
+ */
50
+ maybeCompact(store: HistoryStore, conversationId: string): Promise<void>;
51
+ }
52
+ /** Marks a stored row as a compaction summary rather than a real turn — informational only, nothing parses it back out. */
53
+ export declare const SUMMARY_PREFIX = "Summary of earlier conversation: ";
54
+ /**
55
+ * `deps.client` builds the default `summarize` step; unused when a caller
56
+ * supplies its own.
57
+ */
58
+ export declare function createHistoryCompactor(deps: {
59
+ client: OpenAI;
60
+ }, options: HistoryCompactionOptions): HistoryCompactor;
61
+ //# sourceMappingURL=compaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compaction.d.ts","sourceRoot":"","sources":["../../src/history/compaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAEjC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5D,mIAAmI;AACnI,MAAM,WAAW,gBAAgB;IAC/B,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,cAAc,EAAE,CAAC;CACzB;AAED,0DAA0D;AAC1D,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAEzE,MAAM,WAAW,wBAAwB;IACvC,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAC;IAClB,oIAAoI;IACpI,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,+IAA+I;IAC/I,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uHAAuH;IACvH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;;;OAMG;IACH,YAAY,CAAC,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAWD,2HAA2H;AAC3H,eAAO,MAAM,cAAc,sCAAsC,CAAC;AAoBlE;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EACxB,OAAO,EAAE,wBAAwB,GAChC,gBAAgB,CAgElB"}
@@ -10,8 +10,15 @@
10
10
  * implement it. The shipped Turso implementation needs `@libsql/client`,
11
11
  * which chatter already expects for retrieval.
12
12
  *
13
+ * `./compaction` is an optional layer on top: summarize-then-truncate for a
14
+ * conversation whose window has grown past a threshold, built entirely on
15
+ * the store's own `load`/`clear`/`append` so it works with any
16
+ * `HistoryStore`, not just the Turso one.
17
+ *
13
18
  * @packageDocumentation
14
19
  */
20
+ export type { HistoryCompactionOptions, HistoryCompactor, SummarizeFn, SummarizeRequest, } from "./compaction";
21
+ export { createHistoryCompactor, SUMMARY_PREFIX } from "./compaction";
15
22
  export type { TursoHistoryStoreOptions } from "./tursoStore";
16
23
  export { createTursoHistoryStore } from "./tursoStore";
17
24
  export type { HistoryMessage, HistoryStore } from "./types";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/history/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,YAAY,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/history/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,YAAY,EACV,wBAAwB,EACxB,gBAAgB,EAChB,WAAW,EACX,gBAAgB,GACjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACtE,YAAY,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC"}
@@ -20,10 +20,151 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/history/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ SUMMARY_PREFIX: () => SUMMARY_PREFIX,
24
+ createHistoryCompactor: () => createHistoryCompactor,
23
25
  createTursoHistoryStore: () => createTursoHistoryStore
24
26
  });
25
27
  module.exports = __toCommonJS(index_exports);
26
28
 
29
+ // src/core/guardrails.ts
30
+ function detectLeakage(text) {
31
+ const flags = [
32
+ /system prompt/i,
33
+ /hidden instruction/i,
34
+ /here (are|is) (my|the) rules/i,
35
+ /BEGIN SYSTEM PROMPT/i,
36
+ /tool instructions/i
37
+ ];
38
+ return flags.some((rx) => rx.test(text));
39
+ }
40
+ function scrubOutput(text) {
41
+ return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
42
+ }
43
+ function guardOutput(text) {
44
+ if (detectLeakage(text)) {
45
+ return "Sorry, I can't share internal instructions. How else can I help?";
46
+ }
47
+ return scrubOutput(text);
48
+ }
49
+
50
+ // src/core/llm.ts
51
+ var DEFAULT_MODEL = "gpt-4o";
52
+ async function completeOnce({
53
+ client,
54
+ system,
55
+ messages,
56
+ temperature = 0.2,
57
+ model = DEFAULT_MODEL
58
+ }) {
59
+ const res = await client.chat.completions.create({
60
+ model,
61
+ temperature,
62
+ messages: [{ role: "system", content: system }, ...messages]
63
+ });
64
+ const text = res.choices[0]?.message?.content ?? "";
65
+ return {
66
+ content: guardOutput(text),
67
+ usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
68
+ };
69
+ }
70
+
71
+ // src/core/answer.ts
72
+ var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
73
+ async function runAnswerFn(answerFn, { system, messages, mode, sender, conversationId }) {
74
+ const result = await answerFn({ system, messages, mode, sender, conversationId });
75
+ const raw = typeof result === "string" ? result : result?.content;
76
+ const usage = typeof result === "string" ? void 0 : result?.usage;
77
+ return {
78
+ content: guardOutput(typeof raw === "string" ? raw : ""),
79
+ usage: usage ?? noUsage()
80
+ };
81
+ }
82
+ async function answerOnce({
83
+ answerFn,
84
+ client,
85
+ system,
86
+ messages,
87
+ mode,
88
+ sender,
89
+ conversationId,
90
+ temperature,
91
+ model
92
+ }) {
93
+ if (answerFn) {
94
+ return runAnswerFn(answerFn, { system, messages, mode, sender, conversationId });
95
+ }
96
+ return completeOnce({ client, system, messages, temperature, model });
97
+ }
98
+
99
+ // src/history/compaction.ts
100
+ var DEFAULT_TIMEOUT_MS = 8e3;
101
+ var LOAD_ALL = Number.MAX_SAFE_INTEGER;
102
+ var SUMMARY_PREFIX = "Summary of earlier conversation: ";
103
+ function defaultSummarize(client, model) {
104
+ return async ({ turns }) => {
105
+ const transcript = turns.map((turn) => `${turn.role}: ${turn.content}`).join("\n");
106
+ const { content } = await answerOnce({
107
+ client,
108
+ system: "Summarize the conversation turns below into a short paragraph a reader would need to continue the conversation. Preserve names, facts, and decisions; drop pleasantries. Reply with the summary only, no preamble.",
109
+ messages: [{ role: "user", content: transcript }],
110
+ // No `answerFn`: compaction is an internal chatter operation, not a
111
+ // chat surface, so it must not conscript a caller's brain hook, which
112
+ // is shaped to answer the user, not to summarize on the engine's behalf.
113
+ mode: "public",
114
+ model
115
+ });
116
+ return content;
117
+ };
118
+ }
119
+ function createHistoryCompactor(deps, options) {
120
+ const { threshold, keep, logger } = options;
121
+ if (!Number.isInteger(threshold) || threshold < 1) {
122
+ throw new Error(`Invalid history compaction threshold: ${threshold}`);
123
+ }
124
+ if (!Number.isInteger(keep) || keep < 0 || keep >= threshold) {
125
+ throw new Error(`Invalid history compaction keep: ${keep}`);
126
+ }
127
+ const summarize = options.summarize ?? defaultSummarize(deps.client, options.model);
128
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
129
+ return {
130
+ async maybeCompact(store, conversationId) {
131
+ const turns = await store.load(conversationId, LOAD_ALL);
132
+ if (turns.length < threshold) return;
133
+ const older = turns.slice(0, turns.length - keep);
134
+ let summary;
135
+ let timer;
136
+ try {
137
+ summary = await Promise.race([
138
+ summarize({ conversationId, turns: older }),
139
+ new Promise((_, reject) => {
140
+ timer = setTimeout(() => reject(new Error("history compaction timed out")), timeoutMs);
141
+ })
142
+ ]);
143
+ } catch (error) {
144
+ logger?.error(
145
+ `history compaction summarize failed for "${conversationId}"; history left untouched`,
146
+ error
147
+ );
148
+ return;
149
+ } finally {
150
+ clearTimeout(timer);
151
+ }
152
+ const trimmed = summary.trim();
153
+ if (!trimmed) return;
154
+ const fresh = await store.load(conversationId, LOAD_ALL);
155
+ const recent = fresh.slice(older.length);
156
+ await store.clear(conversationId);
157
+ await store.append(conversationId, {
158
+ role: "assistant",
159
+ content: `${SUMMARY_PREFIX}${trimmed}`
160
+ });
161
+ for (const turn of recent) {
162
+ await store.append(conversationId, turn);
163
+ }
164
+ }
165
+ };
166
+ }
167
+
27
168
  // src/history/tursoStore.ts
28
169
  var schemaReady = /* @__PURE__ */ new WeakMap();
29
170
  function ensureHistorySchema(client, tableName) {
@@ -117,5 +258,7 @@ function createTursoHistoryStore(client, tableName = "chat_history", maxPerConve
117
258
  }
118
259
  // Annotate the CommonJS export names for ESM import in node:
119
260
  0 && (module.exports = {
261
+ SUMMARY_PREFIX,
262
+ createHistoryCompactor,
120
263
  createTursoHistoryStore
121
264
  });
@@ -1,3 +1,142 @@
1
+ // src/core/guardrails.ts
2
+ function detectLeakage(text) {
3
+ const flags = [
4
+ /system prompt/i,
5
+ /hidden instruction/i,
6
+ /here (are|is) (my|the) rules/i,
7
+ /BEGIN SYSTEM PROMPT/i,
8
+ /tool instructions/i
9
+ ];
10
+ return flags.some((rx) => rx.test(text));
11
+ }
12
+ function scrubOutput(text) {
13
+ return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
14
+ }
15
+ function guardOutput(text) {
16
+ if (detectLeakage(text)) {
17
+ return "Sorry, I can't share internal instructions. How else can I help?";
18
+ }
19
+ return scrubOutput(text);
20
+ }
21
+
22
+ // src/core/llm.ts
23
+ var DEFAULT_MODEL = "gpt-4o";
24
+ async function completeOnce({
25
+ client,
26
+ system,
27
+ messages,
28
+ temperature = 0.2,
29
+ model = DEFAULT_MODEL
30
+ }) {
31
+ const res = await client.chat.completions.create({
32
+ model,
33
+ temperature,
34
+ messages: [{ role: "system", content: system }, ...messages]
35
+ });
36
+ const text = res.choices[0]?.message?.content ?? "";
37
+ return {
38
+ content: guardOutput(text),
39
+ usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
40
+ };
41
+ }
42
+
43
+ // src/core/answer.ts
44
+ var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
45
+ async function runAnswerFn(answerFn, { system, messages, mode, sender, conversationId }) {
46
+ const result = await answerFn({ system, messages, mode, sender, conversationId });
47
+ const raw = typeof result === "string" ? result : result?.content;
48
+ const usage = typeof result === "string" ? void 0 : result?.usage;
49
+ return {
50
+ content: guardOutput(typeof raw === "string" ? raw : ""),
51
+ usage: usage ?? noUsage()
52
+ };
53
+ }
54
+ async function answerOnce({
55
+ answerFn,
56
+ client,
57
+ system,
58
+ messages,
59
+ mode,
60
+ sender,
61
+ conversationId,
62
+ temperature,
63
+ model
64
+ }) {
65
+ if (answerFn) {
66
+ return runAnswerFn(answerFn, { system, messages, mode, sender, conversationId });
67
+ }
68
+ return completeOnce({ client, system, messages, temperature, model });
69
+ }
70
+
71
+ // src/history/compaction.ts
72
+ var DEFAULT_TIMEOUT_MS = 8e3;
73
+ var LOAD_ALL = Number.MAX_SAFE_INTEGER;
74
+ var SUMMARY_PREFIX = "Summary of earlier conversation: ";
75
+ function defaultSummarize(client, model) {
76
+ return async ({ turns }) => {
77
+ const transcript = turns.map((turn) => `${turn.role}: ${turn.content}`).join("\n");
78
+ const { content } = await answerOnce({
79
+ client,
80
+ system: "Summarize the conversation turns below into a short paragraph a reader would need to continue the conversation. Preserve names, facts, and decisions; drop pleasantries. Reply with the summary only, no preamble.",
81
+ messages: [{ role: "user", content: transcript }],
82
+ // No `answerFn`: compaction is an internal chatter operation, not a
83
+ // chat surface, so it must not conscript a caller's brain hook, which
84
+ // is shaped to answer the user, not to summarize on the engine's behalf.
85
+ mode: "public",
86
+ model
87
+ });
88
+ return content;
89
+ };
90
+ }
91
+ function createHistoryCompactor(deps, options) {
92
+ const { threshold, keep, logger } = options;
93
+ if (!Number.isInteger(threshold) || threshold < 1) {
94
+ throw new Error(`Invalid history compaction threshold: ${threshold}`);
95
+ }
96
+ if (!Number.isInteger(keep) || keep < 0 || keep >= threshold) {
97
+ throw new Error(`Invalid history compaction keep: ${keep}`);
98
+ }
99
+ const summarize = options.summarize ?? defaultSummarize(deps.client, options.model);
100
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
101
+ return {
102
+ async maybeCompact(store, conversationId) {
103
+ const turns = await store.load(conversationId, LOAD_ALL);
104
+ if (turns.length < threshold) return;
105
+ const older = turns.slice(0, turns.length - keep);
106
+ let summary;
107
+ let timer;
108
+ try {
109
+ summary = await Promise.race([
110
+ summarize({ conversationId, turns: older }),
111
+ new Promise((_, reject) => {
112
+ timer = setTimeout(() => reject(new Error("history compaction timed out")), timeoutMs);
113
+ })
114
+ ]);
115
+ } catch (error) {
116
+ logger?.error(
117
+ `history compaction summarize failed for "${conversationId}"; history left untouched`,
118
+ error
119
+ );
120
+ return;
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ const trimmed = summary.trim();
125
+ if (!trimmed) return;
126
+ const fresh = await store.load(conversationId, LOAD_ALL);
127
+ const recent = fresh.slice(older.length);
128
+ await store.clear(conversationId);
129
+ await store.append(conversationId, {
130
+ role: "assistant",
131
+ content: `${SUMMARY_PREFIX}${trimmed}`
132
+ });
133
+ for (const turn of recent) {
134
+ await store.append(conversationId, turn);
135
+ }
136
+ }
137
+ };
138
+ }
139
+
1
140
  // src/history/tursoStore.ts
2
141
  var schemaReady = /* @__PURE__ */ new WeakMap();
3
142
  function ensureHistorySchema(client, tableName) {
@@ -90,5 +229,7 @@ function createTursoHistoryStore(client, tableName = "chat_history", maxPerConve
90
229
  };
91
230
  }
92
231
  export {
232
+ SUMMARY_PREFIX,
233
+ createHistoryCompactor,
93
234
  createTursoHistoryStore
94
235
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegoaltoworks/chatter",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Embeddable AI chatbot framework with RAG, authentication, and customizable widgets",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",