alvin-bot 5.6.0 → 5.6.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [5.6.1] — 2026-05-18
6
+
7
+ ### Background-task results stay in the chat
8
+
9
+ Results from scheduled and background tasks now appear directly in
10
+ the chat as before. Only an output long enough to span more than two
11
+ messages comes as a single attached file instead — keeping your chat
12
+ tidy without ever splitting a result across a wall of messages. No
13
+ "shortened" notices on normal-sized results; you stay in control of
14
+ when something gets saved as a file.
15
+
16
+ As always, verified with a fresh-install + stress test on a clean
17
+ separate machine.
18
+
5
19
  ## [5.6.0] — 2026-05-18
6
20
 
7
21
  ### Background-task reports are now clean and to the point
package/dist/paths.js CHANGED
@@ -19,8 +19,13 @@ export const DATA_DIR = resolve(process.env.ALVIN_DATA_DIR || resolve(os.homedir
19
19
  export const PUBLIC_DIR = resolve(BOT_ROOT, "web", "public");
20
20
  /** plugins/ — Plugin directory */
21
21
  export const PLUGINS_DIR = resolve(BOT_ROOT, "plugins");
22
- /** skills/ — Skill definitions */
23
- export const SKILLS_DIR = resolve(BOT_ROOT, "skills");
22
+ /** skills/ — Skill definitions.
23
+ * Defaults to BOT_ROOT/skills (repo). Override with ALVIN_SKILLS_DIR so
24
+ * tests can redirect skill writes into a throwaway sandbox instead of
25
+ * polluting the real repo. Default (no env) is byte-identical to before. */
26
+ export const SKILLS_DIR = process.env.ALVIN_SKILLS_DIR
27
+ ? resolve(process.env.ALVIN_SKILLS_DIR)
28
+ : resolve(BOT_ROOT, "skills");
24
29
  /** User skills directory (custom, outside repo) */
25
30
  export const USER_SKILLS_DIR = resolve(DATA_DIR, "skills");
26
31
  /** Example/template files (always in repo) */
@@ -56,52 +56,39 @@ async function sendWithMarkdownFallback(api, chatId, text) {
56
56
  }
57
57
  }
58
58
  const MAX_TG_CHUNK = 3800; // below Telegram's 4096 limit with headroom
59
- // V56-T2 honesty fix — the .md file attachment is no longer gated on a
60
- // separate 20k threshold. It now triggers whenever the cap actually
61
- // truncates (isTruncated → body.length > BODY_CAP), so every truncated
62
- // delivery carries the full output as a file and the marker is honest.
63
- // (The prior 20k-only behavior is fully subsumed by isTruncated.)
64
59
  /**
65
- * V56-T2 (Layer-2)honest hard cap on the INLINE delivered body.
60
+ * Post-v5.6.0 delivery routing by message count, NOT by a truncating
61
+ * cap.
66
62
  *
67
- * V56-T1 made delivery carry the SDK final result instead of the whole
68
- * transcript, but a final result can itself occasionally be very long.
69
- * This bounds the inline-message body so a single agent answer can't
70
- * flood the chat, while staying HONEST.
63
+ * v5.6.0 introduced an inline body cap (1800 chars + a
64
+ * "…(truncated for chat full output attached)" marker) that ALWAYS
65
+ * attached the full body as a `.md` file whenever it truncated. The
66
+ * effect was that even a small ~4 KB result got truncated + filed,
67
+ * which the user disliked. That cap is removed entirely.
71
68
  *
72
- * Honesty contract (fixed after a review found a self-defeating
73
- * regression): whenever `capBody` actually truncates i.e. the body is
74
- * non-empty AND longer than BODY_CAP — the delivery ALSO attaches the
75
- * COMPLETE uncapped output as a `.md` file via the same upload
76
- * mechanism the old >20000-char path already used. The marker
77
- * therefore truthfully says the full output is *attached*, instead of
78
- * the previous wording that pointed at a `~/.alvin-bot/logs/` file the
79
- * cap path never actually wrote. Net effect: any truncated delivery =
80
- * bounded inline message + full `.md` attachment; no lossy inline-only
81
- * range remains. The old >20000 path is unchanged (it already attached
82
- * the full body); this just extends "attach the full file" down to
83
- * "whenever the cap truncated".
69
+ * V56-T1 ("deliver the final result, not the transcript") is kept — a
70
+ * normal final result is usually short and now simply appears inline
71
+ * like it did before v5.6.0.
84
72
  *
85
- * This is a pure bounded slice + a fixed marker — NOT a structure-
86
- * guessing heuristic. It no-ops on empty/whitespace so the
87
- * `(empty output)` truncated-run signal keeps working (and no spurious
88
- * file is attached for it).
89
- */
90
- const BODY_CAP = 1800;
91
- const TRUNCATION_MARKER = "…(truncated for chat — full output attached)";
92
- /**
93
- * True when `capBody` would actually truncate this body the single
94
- * source of truth for "did we drop content, so the full output must be
95
- * attached as a file". Mirrors the `length > BODY_CAP` test in capBody.
73
+ * The body is routed by how many Telegram messages it would need
74
+ * (MAX_TG_CHUNK = 3800):
75
+ * - body 1×MAX_TG_CHUNK → ONE inline message
76
+ * - 1×MAX_TG_CHUNK < body 2× → inline across exactly 2
77
+ * messages (no marker, no file)
78
+ * - body > 2×MAX_TG_CHUNK (≥3 chunks)→ do NOT spam 3+ messages: send
79
+ * the compact header + ONE
80
+ * short neutral note + the FULL
81
+ * (uncapped, complete) body as a
82
+ * `.md` file attachment
83
+ *
84
+ * The `(empty output)` truncated-run signal (~14 chars) is tier-1, so
85
+ * it stays a single inline message with no note and no file.
86
+ *
87
+ * The file in the ≥3-chunk case is the COMPLETE body — nothing is cut,
88
+ * so the note must NOT say "truncated". It is a minimal neutral line.
96
89
  */
97
- function isTruncated(body) {
98
- return body.length > BODY_CAP;
99
- }
100
- function capBody(body) {
101
- if (body.length <= BODY_CAP)
102
- return body;
103
- return `${body.slice(0, BODY_CAP)}\n\n${TRUNCATION_MARKER}`;
104
- }
90
+ const FILE_THRESHOLD = MAX_TG_CHUNK * 2; // > this ⇒ would need ≥3 messages
91
+ const FULL_RESULT_NOTE = "📎 Full result attached (too long for chat).";
105
92
  let injectedApi = null;
106
93
  let runtimeApi = null;
107
94
  /** Test-only hook for injecting a fake bot API. Production code must NEVER call this. */
@@ -346,56 +333,40 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
346
333
  }
347
334
  const banner = buildBanner(info, result);
348
335
  const body = result.output?.trim() || `(empty output)`;
349
- // V56-T2 — bounded variant for the INLINE message path. Whenever this
350
- // actually truncates (isTruncated), the FULL uncapped `body` is also
351
- // attached as a .md file below, so the cap never costs the user
352
- // access to the complete result and the marker stays truthful.
353
- const inlineBody = capBody(body);
354
336
  try {
355
- // Truncated honest delivery: short banner + bounded inline body
356
- // (with the truthful "full output attached" marker) + the COMPLETE
357
- // uncapped body as a .md file. This single branch covers the whole
358
- // truncated range (mid-size AND the old > 20000-char range): there
359
- // is no lossy inline-only range anymore. (The old >20000 behavior
360
- // is unchanged — it already attached the full body; the change is
361
- // that mid-size now also attaches it and the marker no longer
362
- // points at a logs file that was never written.)
363
- if (isTruncated(body)) {
337
+ // Tier 3: body would need ≥3 Telegram messages don't spam the
338
+ // chat. Send the compact header + ONE short neutral note + the FULL
339
+ // (uncapped, COMPLETE) body as a single `.md` file. Nothing is cut,
340
+ // so the note says nothing about truncation.
341
+ if (body.length > FILE_THRESHOLD) {
364
342
  await sendWithMarkdownFallback(api, tgChatId, banner);
365
- // The bounded inline body fits in one message (BODY_CAP=1800 plus
366
- // the short marker is well under MAX_TG_CHUNK); send it as plain
367
- // text so an unbalanced markdown slice can't crash the send.
368
- await api.sendMessage(tgChatId, inlineBody.slice(0, MAX_TG_CHUNK));
343
+ await api.sendMessage(tgChatId, FULL_RESULT_NOTE);
369
344
  try {
370
345
  const { InputFile } = await import("grammy");
371
346
  const buf = Buffer.from(body, "utf-8");
372
347
  await api.sendDocument(tgChatId, new InputFile(buf, `${info.name}.md`));
373
348
  }
374
349
  catch (err) {
375
- // Upload failed → the bounded inline body was already delivered
376
- // above, so the user still has something honest (banner + capped
377
- // text + marker). The marker slightly over-promises here (file
378
- // didn't attach) but this is the rare failure path, not the
379
- // normal one, and there is no silent data loss.
350
+ // Upload failed → the user still has the banner + the note, so
351
+ // they know a result exists and is large. Rare failure path,
352
+ // no silent data loss (nothing was promised inline).
380
353
  console.error(`[subagent-delivery] file upload failed:`, err);
381
354
  }
382
355
  return OK;
383
356
  }
384
- // Not truncated (body BODY_CAP)unchanged passthrough.
385
- // inlineBody === body here (capBody is a no-op), no marker, no file.
386
- // Case A: fits in a single message → banner + body joined
387
- if (inlineBody.length + banner.length + 2 <= MAX_TG_CHUNK) {
388
- await sendWithMarkdownFallback(api, tgChatId, `${banner}\n\n${inlineBody}`);
357
+ // Tier 1: body fits with the banner in a single message join.
358
+ if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
359
+ await sendWithMarkdownFallback(api, tgChatId, `${banner}\n\n${body}`);
389
360
  return OK;
390
361
  }
391
- // Case B: defensive a ≤1800-char body still under-runs MAX_TG_CHUNK
392
- // with the banner, but keep the banner-then-chunk fallback for
393
- // safety against an unusually long banner.
362
+ // Tier 1/2: body alone needs 1 or 2 messages (≤ 2×MAX_TG_CHUNK).
363
+ // Send the banner, then the body chunked across at most 2 messages.
364
+ // No marker, no file this is the pre-v5.6.0 inline behavior.
394
365
  await sendWithMarkdownFallback(api, tgChatId, banner);
395
- for (let i = 0; i < inlineBody.length; i += MAX_TG_CHUNK) {
366
+ for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
396
367
  // Body chunks are always sent as plain text — markdown across
397
368
  // arbitrary chunk boundaries would be inconsistent anyway.
398
- await api.sendMessage(tgChatId, inlineBody.slice(i, i + MAX_TG_CHUNK));
369
+ await api.sendMessage(tgChatId, body.slice(i, i + MAX_TG_CHUNK));
399
370
  }
400
371
  return OK;
401
372
  }
@@ -428,25 +399,16 @@ async function deliverViaRegistry(platform, info, result) {
428
399
  const chatId = info.parentChatId;
429
400
  const banner = buildBannerPlain(info, result);
430
401
  const body = result.output?.trim() || `(empty output)`;
431
- // V56-T2 same honest contract as the Telegram path. Whenever the
432
- // cap truncates, the FULL uncapped `body` is attached as a .md file
433
- // (if the adapter supports uploads) so the marker stays truthful and
434
- // the complete output remains accessible.
435
- const inlineBody = capBody(body);
436
- const NON_TG_CHUNK = 3800;
402
+ const NON_TG_CHUNK = MAX_TG_CHUNK; // same conservative 3800 cap
437
403
  try {
438
- // Truncated honest delivery: banner + bounded inline body (with
439
- // the truthful "full output attached" marker) + the COMPLETE
440
- // uncapped body as a .md file. Covers the whole truncated range
441
- // (mid-size AND > the old 20k threshold)no lossy inline-only
442
- // range remains. If the adapter has no sendDocument or the upload
443
- // fails, the bounded inline body still went out (honest, just no
444
- // file) — no silent data loss.
445
- if (isTruncated(body)) {
404
+ // Tier 3: body would need ≥3 messages don't spam the channel.
405
+ // Send the banner + ONE short neutral note + the FULL (uncapped,
406
+ // COMPLETE) body as a `.md` file (if the adapter supports uploads).
407
+ // Mirrors the Telegram path exactly. No truncationthe file is
408
+ // the complete result.
409
+ if (body.length > FILE_THRESHOLD) {
446
410
  await adapter.sendText(chatId, banner);
447
- for (let i = 0; i < inlineBody.length; i += NON_TG_CHUNK) {
448
- await adapter.sendText(chatId, inlineBody.slice(i, i + NON_TG_CHUNK));
449
- }
411
+ await adapter.sendText(chatId, FULL_RESULT_NOTE);
450
412
  if (adapter.sendDocument) {
451
413
  try {
452
414
  await adapter.sendDocument(chatId, Buffer.from(body, "utf-8"), `${info.name}.md`);
@@ -457,16 +419,16 @@ async function deliverViaRegistry(platform, info, result) {
457
419
  }
458
420
  return;
459
421
  }
460
- // Not truncated (body BODY_CAP)unchanged passthrough.
461
- // inlineBody === body here, no marker, no file.
462
- if (inlineBody.length + banner.length + 2 <= NON_TG_CHUNK) {
463
- await adapter.sendText(chatId, `${banner}\n\n${inlineBody}`);
422
+ // Tier 1: body + banner fit in one message join.
423
+ if (body.length + banner.length + 2 <= NON_TG_CHUNK) {
424
+ await adapter.sendText(chatId, `${banner}\n\n${body}`);
464
425
  return;
465
426
  }
466
- // Defensive banner-then-chunk fallback (e.g. unusually long banner).
427
+ // Tier 1/2: banner, then body chunked across at most 2 messages.
428
+ // No marker, no file.
467
429
  await adapter.sendText(chatId, banner);
468
- for (let i = 0; i < inlineBody.length; i += NON_TG_CHUNK) {
469
- await adapter.sendText(chatId, inlineBody.slice(i, i + NON_TG_CHUNK));
430
+ for (let i = 0; i < body.length; i += NON_TG_CHUNK) {
431
+ await adapter.sendText(chatId, body.slice(i, i + NON_TG_CHUNK));
470
432
  }
471
433
  }
472
434
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "5.6.0",
3
+ "version": "5.6.1",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",