Cap #rss get summaries to one mesh packet (v0.20.1)
CI / build + unit (pull_request) Successful in 42s

Replies >~190 chars fragment into fire-and-forget packets recipients
often never receive; old prompt produced ~326-334 char replies.

Prompt now asks for at most 3 short bullet lines, each under 45 chars,
bullets only (verified live: exactly 3 compliant bullets, ~110 chars).
fitOnePacket() backstop cuts any over-length reply at complete-bullet
boundaries so delivery is one packet even if the model ignores the prompt.

maxTokens 200 -> 2500 on the summary chat(): empirically verified that
this server's reasoning tokens count against max_tokens (cap 150 ->
completion 150 of which 105 were reasoning; cap 500 fully consumed by
reasoning, zero visible content) and thinking is uncontrollable
server-side (/no_think, chat_template_kwargs.enable_thinking,
thinking_budget all ignored), observed 32..1776 tokens for this task.
2500 covers every observed run + ~80 content tokens, worst case ~42 s
under the 60 s LLM timeout.
This commit is contained in:
2026-09-25 19:22:13 +00:00
parent a8ce04d3f8
commit e2d712a26c
5 changed files with 88 additions and 9 deletions
+49 -4
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 { RssService } from './rss.service.js';
import { fitOnePacket, RssService } from './rss.service.js';
const FEED_A = `<rss version="2.0"><channel>
<item><title>Alpha headline</title></item>
@@ -158,9 +158,31 @@ describe('RssService.handle', () => {
expect(reply).toBe('Alpha and beta happened.');
const [messages, opts] = llm.chat.mock.calls[0];
expect(opts).toEqual({ maxTokens: 200 });
expect(messages[0].content).toContain('Alpha headline');
expect(messages[0].content).toContain('Beta headline');
// 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('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 () => {
@@ -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 MAX_TITLES = 8; // "up to 8 latest item titles" per the design
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
@@ -163,15 +173,16 @@ export class RssService {
if (!titles.length) return 'no items found';
try {
return await this.llm.chat(
const reply = await this.llm.chat(
[
{
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 },
);
return fitOnePacket(reply);
} catch (err) {
this.logger.warn(`[rss] summary failed: ${(err as Error).message}`);
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. */
export function sanitizeTags(raw: string): string[] {
const seen = new Set<string>();