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
+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>();