Merge pull request 'Cap #rss get summaries to one mesh packet (v0.20.1)' (#42) from fix/rss-summary-length into main
CI / build + unit (push) Successful in 1m10s

This commit was merged in pull request #42.
This commit is contained in:
2026-09-25 21:25:02 +02:00
5 changed files with 88 additions and 9 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. - 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`. - 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, maxTokens 200; `#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 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.
- 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`). - 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`).
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 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.
- The summary prompt now asks for "at most 3 short bullet lines, each under 45 characters … bullets only - no intro, no other text" (verified against the live LLM: exactly 3 compliant bullets, ~110 chars). A `fitOnePacket()` backstop additionally cuts any over-length reply at complete-bullet boundaries so delivery is one packet even if the model ignores the prompt.
- The summary `chat()` call now uses an explicit `maxTokens = 2500` (was 200). Empirically verified on this server: qwen3.8-27b emits `reasoning_content` and those tokens COUNT against max_tokens (cap 150 → completion_tokens 150, of which 105 were reasoning, finish_reason 'length'; a cap of 500 was fully consumed by reasoning with zero visible content). Thinking length for this task is unbounded and uncontrollable server-side (`/no_think`, `chat_template_kwargs.enable_thinking` and `thinking_budget` are all ignored) — observed 32..1776 tokens across runs. 2500 covers every observed run plus ~80 content tokens, and keeps the worst case (~42 s at ~60 tok/s) under the 60 s LLM timeout. The old cap of 200 guaranteed empty replies whenever reasoning exceeded it.
- Tests: 154 → 158 — prompt shape + maxTokens asserted on the summary call, over-length reply cut through `handle()`, and direct `fitOnePacket` specs (pass-through at/under 190, bullet-boundary cut, hard-cut fallback for non-bulleted paragraphs).
## v0.20.0 — 2026-09-25 18:40 ## v0.20.0 — 2026-09-25 18:40
- New `#rss` command family on the command channel (ch2): `#rss help`, `#rss add <url> <name>` (validates URL + fetches with a 15 s timeout, requires ≥1 item with a non-empty title, rejects duplicate names case-insensitively; tags are generated by the LLM from up to 8 latest titles — on LLM failure the source is still added with empty tags and the reply says so), `#rss remove <name>`, `#rss tags` (all distinct tags), `#rss sources`, `#rss get <name>` and `#rss get #<tag>` (LLM headline summary, maxTokens 200; tag form collects items across ALL sources carrying the tag — one dead feed does not sink the summary). Bare `#rss` / unknown subcommand → help. All replies ride the existing respond() path like `#ping`. - New `#rss` command family on the command channel (ch2): `#rss help`, `#rss add <url> <name>` (validates URL + fetches with a 15 s timeout, requires ≥1 item with a non-empty title, rejects duplicate names case-insensitively; tags are generated by the LLM from up to 8 latest titles — on LLM failure the source is still added with empty tags and the reply says so), `#rss remove <name>`, `#rss tags` (all distinct tags), `#rss sources`, `#rss get <name>` and `#rss get #<tag>` (LLM headline summary, maxTokens 200; tag form collects items across ALL sources carrying the tag — one dead feed does not sink the summary). Bare `#rss` / unknown subcommand → help. All replies ride the existing respond() path like `#ping`.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.20.0", "version": "0.20.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+49 -4
View File
@@ -5,7 +5,7 @@ import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LlmService } from '../llm/llm.service.js'; import type { LlmService } from '../llm/llm.service.js';
import { RssStore } from './rss.store.js'; import { RssStore } from './rss.store.js';
import { RssService } from './rss.service.js'; import { fitOnePacket, RssService } from './rss.service.js';
const FEED_A = `<rss version="2.0"><channel> const FEED_A = `<rss version="2.0"><channel>
<item><title>Alpha headline</title></item> <item><title>Alpha headline</title></item>
@@ -158,9 +158,31 @@ describe('RssService.handle', () => {
expect(reply).toBe('Alpha and beta happened.'); expect(reply).toBe('Alpha and beta happened.');
const [messages, opts] = llm.chat.mock.calls[0]; const [messages, opts] = llm.chat.mock.calls[0];
expect(opts).toEqual({ maxTokens: 200 }); // headroom for the server's reasoning tokens (they count against max_tokens) — see rss.service.ts
expect(messages[0].content).toContain('Alpha headline'); expect(opts).toEqual({ maxTokens: 2500 });
expect(messages[0].content).toContain('Beta headline'); 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('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 () => {
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))
.join('\n');
llm.chat.mockResolvedValueOnce(tooLong);
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);
}); });
it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => { it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => {
@@ -204,3 +226,26 @@ 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);
});
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))
.join('\n');
expect(fitOnePacket(five).split('\n')).toHaveLength(4);
expect(fitOnePacket(five).length).toBeLessThanOrEqual(190);
});
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));
});
});
+30 -3
View File
@@ -10,7 +10,17 @@ import { RssStore } from './rss.store.js';
const FETCH_TIMEOUT_MS = 15000; // feed fetches — same budget as the opencode notifier's POSTs const FETCH_TIMEOUT_MS = 15000; // feed fetches — same budget as the opencode notifier's POSTs
const MAX_TITLES = 8; // "up to 8 latest item titles" per the design const MAX_TITLES = 8; // "up to 8 latest item titles" per the design
const TAG_MAX_TOKENS = 50; const TAG_MAX_TOKENS = 50;
const SUMMARY_MAX_TOKENS = 200; // The LLM server (qwen3.8-27b) emits reasoning_content and those tokens COUNT against max_tokens
// (verified: cap 150 → completion_tokens 150 of which 105 were reasoning, finish_reason 'length').
// Thinking length for this task is unbounded and uncontrollable server-side (/no_think,
// chat_template_kwargs.enable_thinking and thinking_budget are all ignored) — observed 32..1776
// tokens across runs. So the cap must be headroom for reasoning + ~80 content tokens:
// 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;
/** /**
* The `#rss` command family: stored RSS sources (SQLite, shared MESH_DB_PATH file) with * The `#rss` command family: stored RSS sources (SQLite, shared MESH_DB_PATH file) with
@@ -163,15 +173,16 @@ export class RssService {
if (!titles.length) return 'no items found'; if (!titles.length) return 'no items found';
try { try {
return await this.llm.chat( const reply = await this.llm.chat(
[ [
{ {
role: 'user', role: 'user',
content: `Summarize these RSS headlines into a few short one-line highlights. Be concise:\n${titles.join('\n')}`, 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')}`,
}, },
], ],
{ maxTokens: SUMMARY_MAX_TOKENS }, { maxTokens: SUMMARY_MAX_TOKENS },
); );
return fitOnePacket(reply);
} catch (err) { } catch (err) {
this.logger.warn(`[rss] summary failed: ${(err as Error).message}`); this.logger.warn(`[rss] summary failed: ${(err as Error).message}`);
return 'summary failed'; return 'summary failed';
@@ -190,6 +201,22 @@ 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);
}
// non-bulleted paragraph: a hard cut still beats an empty reply
return kept.length ? kept.join('\n') : reply.slice(0, MAX_REPLY_CHARS);
}
/** Normalize LLM tag output: lowercase, no #, whitespace → '-', deduped. */ /** Normalize LLM tag output: lowercase, no #, whitespace → '-', deduped. */
export function sanitizeTags(raw: string): string[] { export function sanitizeTags(raw: string): string[] {
const seen = new Set<string>(); const seen = new Set<string>();