Compare commits

...
2 Commits
Author SHA1 Message Date
kamilkrzywda 73ce49c741 Merge pull request '#rss get: 3-5 separate messages with short headers (v0.21.0)' (#43) from feat/rss-multi-message into main
CI / build + unit (push) Successful in 41s
2026-09-25 22:12:05 +02:00
kamilkrzywda cdd52a3ddb Send #rss get summaries as 3-5 separate mesh messages (v0.21.0)
CI / build + unit (pull_request) Successful in 1m20s
The single-message summary was too small (owner request). The summarizer
prompt now asks for exactly 3-5 lines, one item per line, in the format
'<short header>: <one or two short sentences>' with a <=20-char header,
no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom).

The reply is parsed into items: drop empty lines and lines without a ':'
header separator, cap at 5; fewer than 3 valid lines sends what we got
(no failure/retry), zero valid lines sends nothing. Each item goes out as
its own mesh message via respond(id, line) - all threaded to the trigger
packet id on its channel. Per-item backstop fitOneLine() hard-cuts any
line >185 chars at a word boundary near 180 so every item fits one packet;
the old whole-reply fitOnePacket() cut is gone.

CommandService sends array replies line by line through the same routing
as single replies (respond per line, or legacy sendText fallback per line
when there is no packet id). LLM failure path unchanged: single
'summary failed' reply.
2026-09-25 20:09:16 +00:00
7 changed files with 183 additions and 75 deletions
+1 -1
View File
@@ -27,5 +27,5 @@
- MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages.
- Hourly cron weather report job (`src/weather`): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env `WEATHER_CRON`/`WEATHER_LAT`/`WEATHER_LON`/`WEATHER_CITY`.
- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns reply string or void = no reply). First command: `#ping` → replies `#pong!`. Replies go through `MeshService.respond(id, reply)`: auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with `replyId = id` so the recipient app renders it as a threaded quote — `COMMAND_CHANNEL` is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on `COMMAND_CHANNEL`. A failing handler/respond only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. Prefix dispatch (`registerPrefix(name, fn)`) lets one handler own a whole family: the `#rss` subcommands live in `src/rss` (`RssService.handle`, registered as prefix `'rss'` from the CommandModule factory — exact matches still win over prefixes). Subcommands: `#rss help`; `#rss add <url> <name>` (validates URL, fetches with 15 s timeout, requires ≥1 titled item, rejects duplicate names case-insensitively; LLM generates 2-4 lowercase tags from up to 8 latest titles — on LLM failure the source is added with empty tags and the reply says so); `#rss remove <name>`; `#rss tags` (all distinct tags); `#rss sources`; `#rss get <name|#tag>` (LLM headline summary capped to ONE mesh packet: prompt asks for ≤3 bullet lines × <45 chars, `fitOnePacket()` cuts any over-length reply at complete-bullet boundaries to ≤190 chars; maxTokens 2500 is headroom because the LLM server's reasoning tokens count against it — see rss.service.ts comment; `#tag` collects items across ALL sources carrying that tag — one dead feed does not sink the summary). Storage: `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file via a second node:sqlite connection (names matched case-insensitively); feed parsing via `fast-xml-parser` (RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injected `LlmService.chat()` with explicit maxTokens (50 tags / 200 summary) to keep mesh replies short.
- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns a reply string — or an array of lines, sent as one message per line through the same routing — or void = no reply). First command: `#ping` → replies `#pong!`. Replies go through `MeshService.respond(id, reply)`: auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with `replyId = id` so the recipient app renders it as a threaded quote — `COMMAND_CHANNEL` is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on `COMMAND_CHANNEL`. A failing handler/respond only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. Prefix dispatch (`registerPrefix(name, fn)`) lets one handler own a whole family: the `#rss` subcommands live in `src/rss` (`RssService.handle`, registered as prefix `'rss'` from the CommandModule factory — exact matches still win over prefixes). Subcommands: `#rss help`; `#rss add <url> <name>` (validates URL, fetches with 15 s timeout, requires ≥1 titled item, rejects duplicate names case-insensitively; LLM generates 2-4 lowercase tags from up to 8 latest titles — on LLM failure the source is added with empty tags and the reply says so); `#rss remove <name>`; `#rss tags` (all distinct tags); `#rss sources`; `#rss get <name|#tag>` (LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line, `<short header>: <one or two short sentences>` with ≤20-char header, no intro/outro/numbering; reply parsed into items — drop empty/separator-less lines, cap at 5, fewer than 3 → send what we got (no retry), zero → no message; each item sent via its own respond() threaded to the trigger packet id, per-line backstop `fitOneLine()` hard-cuts >185-char lines at a word boundary near 180 so every item fits one packet; maxTokens 2500 is headroom because the LLM server's reasoning tokens count against it — see rss.service.ts comment; `#tag` collects items across ALL sources carrying that tag — one dead feed does not sink the summary). Storage: `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file via a second node:sqlite connection (names matched case-insensitively); feed parsing via `fast-xml-parser` (RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injected `LlmService.chat()` with explicit maxTokens (50 tags / 2500 summary headroom).
- LLM client (`src/llm`): `LlmService` wraps the local OpenAI-compatible server (default `http://192.168.1.70:1111/v1`, no auth) with native fetch — plain feature module, no Mesh/Schedule dependency. Methods: `listModels()` → `data[].id`; `chat(messages, { maxTokens?, temperature? })` → `choices[0].message.content` (non-2xx throws with status + body snippet); `summarize(text)` = chat() with system prompt "Summarize the following text concisely." Env: `LLM_API_BASE`, `LLM_MODEL` (default `qwen3.8-27b`), `LLM_TIMEOUT_MS` (default 60000, via `AbortSignal.timeout`).
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## v0.21.0 — 2026-09-25 19:59
- `#rss get <name|#tag>` now replies with **3–5 SEPARATE mesh messages** instead of one small summary (owner request: the single-message summary was too small). Each item is its own packet, so no fragmentation — and each rides the trigger's channel threaded as a quote of the command packet.
- New summarizer prompt asks for exactly 3-5 lines, one item per line, format `<short header>: <one or two short sentences>` with a ≤20-char header (e.g. `Culture: Museum opens new wing`), no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom — see v0.20.1).
- The LLM reply is parsed into items: split on newlines, trim, drop empty lines and lines without a `:` header separator, cap at 5. Fewer than 3 valid lines → send what we got (no failure, no retry — the model is non-deterministic); zero valid lines → no message sent.
- Per-item backstop replaces the old whole-reply cut: any single line >185 chars is hard-cut at a word boundary near 180 (`fitOneLine`), so every item fits one packet even if the model ignores the prompt. `fitOnePacket()` (whole-reply ≤190) is gone — it no longer applies to joined text.
- CommandService now sends array replies line by line: each element goes through the same routing as a single reply (`respond(id, line)` threaded to the trigger packet id; legacy sendText fallback per line when there is no packet id). A failing send stops the remaining lines (warn log), like any failed command.
- LLM failure path unchanged: single `summary failed` reply via respond(). `#rss get #tag` gets the same multi-message treatment automatically (shared summary path — covered by tests). All other subcommands unchanged.
- Tests: 158 → 165 — prompt shape + maxTokens asserted on the new call, 5-item cap, invalid-line filtering, fewer-than-3 fallback, per-line over-length cut through `handle()`, direct `parseSummaryItems`/`fitOneLine` specs, and CommandService array-reply routing (threaded respond ×N to one packet id; legacy sendText fallback).
## v0.20.1 — 2026-09-25 19:20
- `#rss get` summaries now fit ONE mesh packet (≤190 chars). Mesh fragments longer replies into fire-and-forget packets that recipients frequently never receive, and the old prompt ("a few short one-line highlights") produced ~326-334 char replies.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.20.1",
"version": "0.21.0",
"description": "",
"author": "",
"private": true,
+24
View File
@@ -114,6 +114,30 @@ describe('CommandService.onRx', () => {
expect(mesh.respond).toHaveBeenCalledWith(5, 'up from 99');
});
it('sends each line of an array reply as its own threaded respond() to the same packet id', async () => {
const [svc, mesh] = makeService();
svc.register('multi', () => ['one: first item', 'two: second item']);
await svc.onRx(2, '#multi', 42, 9);
expect(mesh.respond).toHaveBeenCalledTimes(2);
expect(mesh.respond.mock.calls[0]).toEqual([9, 'one: first item']);
expect(mesh.respond.mock.calls[1]).toEqual([9, 'two: second item']);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
const [svc, mesh] = makeService();
svc.register('multi', () => ['one: first item', 'two: second item']);
await svc.onRx(2, '#multi', 42); // no id → legacy path on COMMAND_CHANNEL, DM back to the sender
expect(mesh.sendText).toHaveBeenCalledTimes(2);
expect(mesh.sendText.mock.calls[0]).toEqual([
42,
'one: first item',
undefined,
2,
]);
expect(mesh.respond).not.toHaveBeenCalled();
});
it('swallows a failing handler (a bad command must not crash the app)', async () => {
const [svc, mesh] = makeService();
svc.register('boom', () => {
+8 -5
View File
@@ -14,10 +14,10 @@ export interface CommandContext {
id?: number;
}
/** A command handler: returns the reply text to send back, or void/undefined for no reply. */
/** A command handler: returns the reply text(s) to send back — an array sends one message per line — or void/undefined for no reply. */
export type CommandHandler = (
ctx: CommandContext,
) => string | undefined | Promise<string | undefined>;
) => string | string[] | undefined | Promise<string | string[] | undefined>;
@Injectable()
export class CommandService {
@@ -83,18 +83,21 @@ export class CommandService {
try {
const reply = await handler({ text: trimmed, from, id });
if (reply === undefined || reply === null) return; // void = no reply
// an array reply sends one message per line, all threaded to the same trigger packet id
for (const line of Array.isArray(reply) ? reply : [reply]) {
if (id !== undefined && this.mesh) {
// respond() auto-routes the reply: rides the original record's channel with replyId = id (threaded quote)
await this.mesh.respond(id, reply);
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
await this.mesh.respond(id, line);
} else {
// defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL
await this.mesh?.sendText(
from ?? BROADCAST_ADDR,
reply,
line,
undefined,
this.commandChannel,
);
}
}
} catch (err) {
this.logger.warn(
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
+101 -35
View File
@@ -5,7 +5,7 @@ import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LlmService } from '../llm/llm.service.js';
import { RssStore } from './rss.store.js';
import { fitOnePacket, RssService } from './rss.service.js';
import { fitOneLine, parseSummaryItems, RssService } from './rss.service.js';
const FEED_A = `<rss version="2.0"><channel>
<item><title>Alpha headline</title></item>
@@ -149,40 +149,79 @@ describe('RssService.handle', () => {
expect(await svc.handle(ctx('#rss sources'))).toBe('Aye, Bee');
});
it('get <name>: summarizes the latest titles via chat with a bounded maxTokens', async () => {
it('get <name>: summarizes via chat with a bounded maxTokens and replies with headered lines', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce('Alpha and beta happened.');
llm.chat.mockResolvedValueOnce(
'Chips: EU funds foundries.\nPark: Riverfront expansion approved.',
);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toBe('Alpha and beta happened.');
expect(reply).toEqual([
'Chips: EU funds foundries.',
'Park: Riverfront expansion approved.',
]);
const [messages, opts] = llm.chat.mock.calls[0];
// headroom for the server's reasoning tokens (they count against max_tokens) — see rss.service.ts
expect(opts).toEqual({ maxTokens: 2500 });
const prompt = messages[0].content;
expect(prompt).toContain('at most 3 short bullet lines');
expect(prompt).toContain('each under 45 characters');
expect(prompt).toContain('no intro, no other text');
expect(prompt).toContain('exactly 3-5 lines');
expect(prompt).toContain('<short header>: <one or two short sentences>');
expect(prompt).toContain('at most 20 characters');
expect(prompt).toContain('No intro, no outro, no numbering');
expect(prompt).toContain('Alpha headline');
expect(prompt).toContain('Beta headline');
});
it('get <name>: cuts an over-length LLM reply at bullet boundaries so it fits one packet', async () => {
it('get <name>: caps items at 5', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
// 5 bullets × 46 chars = 234 joined — over the 190-char one-packet cap
const tooLong = ['a', 'b', 'c', 'd', 'e']
.map((c) => `-${c}`.padEnd(46, c))
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
.map((c) => `${c}: item ${c}`)
.join('\n');
llm.chat.mockResolvedValueOnce(tooLong);
llm.chat.mockResolvedValueOnce(seven);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply.length).toBeLessThanOrEqual(190);
// the first 4 bullets fit (47*4-1=187), the 5th does not — and only complete lines survive
expect(reply.split('\n')).toHaveLength(4);
for (const line of reply.split('\n')) expect(line.length).toBe(46);
expect(reply).toEqual([
'a: item a',
'b: item b',
'c: item c',
'd: item d',
'e: item e',
]);
});
it('get <name>: drops empty and separator-less lines, sends what remains when fewer than 3 are valid', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce(
'Chips: EU funds foundries.\n\nJust a sentence without a header\n \nPark: expansion approved.',
);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toEqual([
'Chips: EU funds foundries.',
'Park: expansion approved.',
]);
});
it('get <name>: hard-cuts an over-length line at a word boundary so each item fits one packet', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
const longLine = `Chips: ${'word '.repeat(40)}`; // 207 chars — over the per-line cap
llm.chat.mockResolvedValueOnce(`${longLine}\nPark: short line.`);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(Array.isArray(reply)).toBe(true);
const [cut, ok] = reply as string[];
expect(cut.length).toBeLessThanOrEqual(180);
expect(longLine.startsWith(cut)).toBe(true); // prefix — cut at a word boundary, not mid-word
expect(cut.endsWith(' ')).toBe(false);
expect(ok).toBe('Park: short line.'); // other lines untouched
});
it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => {
@@ -193,11 +232,11 @@ describe('RssService.handle', () => {
.mockResolvedValueOnce(feedResponse(FEED_A)) // A answers (Alpha, Beta)
.mockRejectedValueOnce(new Error('down')); // B is dead
llm.chat.mockResolvedValueOnce('Only alpha survived.');
llm.chat.mockResolvedValueOnce('Chips: only alpha survived.');
const reply = await svc.handle(ctx('#rss get #tech'));
// B's fetch rejected, yet the summary still went out from A's titles alone
expect(reply).toBe('Only alpha survived.');
expect(reply).toEqual(['Chips: only alpha survived.']);
expect(llm.chat).toHaveBeenCalledTimes(1);
const content = llm.chat.mock.calls[0][0][0].content;
expect(content).toContain('Alpha headline');
@@ -210,9 +249,15 @@ describe('RssService.handle', () => {
.mockResolvedValueOnce(feedResponse(FEED_A)) // A → Alpha, Beta
.mockResolvedValueOnce(feedResponse(FEED_B)); // B → Gamma
llm.chat.mockResolvedValueOnce('all three.');
await svc.handle(ctx('#rss get #tech'));
llm.chat.mockResolvedValueOnce(
'Alpha: first headline.\nGamma: second headline.',
);
const reply = await svc.handle(ctx('#rss get #tech'));
expect(reply).toEqual([
'Alpha: first headline.',
'Gamma: second headline.',
]);
const content = llm.chat.mock.calls[0][0][0].content;
expect(content).toContain('Alpha headline');
expect(content).toContain('Gamma headline'); // proves B's feed was collected too
@@ -227,25 +272,46 @@ describe('RssService.handle', () => {
});
});
describe('fitOnePacket (one mesh packet ≤ 190 chars)', () => {
it('passes through replies that already fit, including exactly 190 chars', () => {
const short = '- a\n- b';
expect(fitOnePacket(short)).toBe(short);
const exact = 'x'.repeat(190);
expect(fitOnePacket(exact)).toBe(exact);
describe('parseSummaryItems (per-line items for multi-message summaries)', () => {
it('keeps lines with a header separator, drops empty and separator-less ones', () => {
expect(
parseSummaryItems(
'Chips: EU funds foundries.\n\nNo header here\n \nPark: expansion approved.',
),
).toEqual(['Chips: EU funds foundries.', 'Park: expansion approved.']);
});
it('keeps complete bullet lines that fit and drops the rest', () => {
// 46-char bullets: 4 fit (47*4-1=187), 5 do not (234)
const five = ['a', 'b', 'c', 'd', 'e']
.map((c) => `-${c}`.padEnd(46, c))
it('caps at 5 items', () => {
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
.map((c) => `${c}: item`)
.join('\n');
expect(fitOnePacket(five).split('\n')).toHaveLength(4);
expect(fitOnePacket(five).length).toBeLessThanOrEqual(190);
expect(parseSummaryItems(seven)).toHaveLength(5);
expect(parseSummaryItems(seven)[4]).toBe('e: item');
});
it('hard-cuts a non-bulleted over-length paragraph rather than replying empty', () => {
const para = 'w'.repeat(250);
expect(fitOnePacket(para)).toBe('w'.repeat(190));
it('returns [] when nothing is valid', () => {
expect(parseSummaryItems('no headers at all\n')).toEqual([]);
});
});
describe('fitOneLine (one mesh packet per line)', () => {
it('passes through lines that already fit, including exactly 185 chars', () => {
const short = 'Chips: EU funds foundries.';
expect(fitOneLine(short)).toBe(short);
const exact = 'x'.repeat(185);
expect(fitOneLine(exact)).toBe(exact);
});
it('cuts over-long lines at a word boundary near 180', () => {
const long = `Chips: ${'word '.repeat(40)}`; // 207 chars
const cut = fitOneLine(long);
expect(cut.length).toBeLessThanOrEqual(180);
expect(long.startsWith(cut)).toBe(true); // prefix — not mid-word
expect(cut.endsWith(' ')).toBe(false);
});
it('hard-cuts at 180 when there is no word boundary in reach', () => {
const long = 'w'.repeat(250);
expect(fitOneLine(long)).toBe('w'.repeat(180));
});
});
+29 -24
View File
@@ -18,14 +18,16 @@ const TAG_MAX_TOKENS = 50;
// 2500 covers every observed run (worst case ≈42 s at ~60 tok/s, under LLM_TIMEOUT_MS=60 s).
const SUMMARY_MAX_TOKENS = 2500;
// Mesh fragments replies longer than ~190 chars into fire-and-forget packets that recipients
// often never get — a summary must fit ONE packet. The prompt asks for ≤3 bullets × <45 chars;
// this is the hard backstop if the model ignores it.
const MAX_REPLY_CHARS = 190;
// often never get — each summary line must fit ONE packet on its own. The prompt asks for short
// headered lines; this is the hard backstop if the model ignores it (cut at a word boundary).
const MAX_LINE_CHARS = 185; // per-line one-packet threshold
const LINE_CUT_AT = 180; // where over-long lines get cut, snapped back to a word boundary
const MAX_ITEMS = 5; // cap on summary items sent as separate messages
/**
* The `#rss` command family: stored RSS sources (SQLite, shared MESH_DB_PATH file) with
* LLM-generated tags and on-demand headline summaries. All replies are short strings —
* CommandService routes them through MeshService.respond() like any other command reply.
* LLM-generated tags and on-demand headline summaries. Replies are short strings — or an array
* of lines for multi-message summaries, which CommandService sends one respond() per line.
*/
@Injectable()
export class RssService {
@@ -39,8 +41,8 @@ export class RssService {
this.store = new RssStore(db);
}
/** Entry point for the `#rss` prefix — always replies (errors included). */
async handle(ctx: CommandContext): Promise<string> {
/** Entry point for the `#rss` prefix — always replies (errors included); `get` may reply with several lines. */
async handle(ctx: CommandContext): Promise<string | string[]> {
const words = ctx.text.trim().split(/\s+/);
switch (words[1]) {
case undefined: // bare `#rss`
@@ -141,8 +143,8 @@ export class RssService {
return names.length ? names.join(', ') : 'no sources yet';
}
/** `#rss get <name>` or `#rss get #<tag>`. */
private async get(target: string): Promise<string> {
/** `#rss get <name>` or `#rss get #<tag>` — replies with 3-5 separate one-packet lines. */
private async get(target: string): Promise<string | string[]> {
if (!target) return 'usage: #rss get <name|#tag>';
let urls: string[];
@@ -177,12 +179,14 @@ export class RssService {
[
{
role: 'user',
content: `Summarize these RSS headlines into at most 3 short bullet lines, each under 45 characters. Reply with the bullets only - no intro, no other text:\n${titles.join('\n')}`,
content: `Summarize these RSS headlines into exactly 3-5 lines, one item per line, each in the format "<short header>: <one or two short sentences>" where the header is at most 20 characters (e.g. "Culture: Museum opens new wing"). No intro, no outro, no numbering - items only:\n${titles.join('\n')}`,
},
],
{ maxTokens: SUMMARY_MAX_TOKENS },
);
return fitOnePacket(reply);
// each item goes out as its own mesh message — CommandService sends the array line by line;
// fewer than 3 valid lines is fine (the model is non-deterministic), we send what we got
return parseSummaryItems(reply).map(fitOneLine);
} catch (err) {
this.logger.warn(`[rss] summary failed: ${(err as Error).message}`);
return 'summary failed';
@@ -201,20 +205,21 @@ export class RssService {
}
}
/** Keep only complete bullet lines that fit one mesh packet (≤ MAX_REPLY_CHARS). */
export function fitOnePacket(reply: string): string {
if (reply.length <= MAX_REPLY_CHARS) return reply;
const kept: string[] = [];
for (const line of reply.split('\n')) {
const bullet = line.trim();
if (!bullet) continue;
// Σ(len+1): exactly the joined-with-\n length this would have AFTER appending `bullet`
const total = kept.reduce((n, b) => n + b.length + 1, 0);
if (total + bullet.length > MAX_REPLY_CHARS) break;
kept.push(bullet);
/** Parse an LLM summary into per-line items: drop empty lines and lines without a `:` header separator, cap at MAX_ITEMS. */
export function parseSummaryItems(reply: string): string[] {
return reply
.split('\n')
.map((l) => l.trim())
.filter((l) => l && l.includes(':'))
.slice(0, MAX_ITEMS);
}
// non-bulleted paragraph: a hard cut still beats an empty reply
return kept.length ? kept.join('\n') : reply.slice(0, MAX_REPLY_CHARS);
/** Hard-cut a single summary line at a word boundary near LINE_CUT_AT when it exceeds MAX_LINE_CHARS. */
export function fitOneLine(line: string): string {
if (line.length <= MAX_LINE_CHARS) return line;
const slice = line.slice(0, LINE_CUT_AT);
const at = slice.lastIndexOf(' ');
return (at > 0 ? slice.slice(0, at) : slice).trimEnd();
}
/** Normalize LLM tag output: lowercase, no #, whitespace → '-', deduped. */