Cap #rss get summaries to one mesh packet (v0.20.1)
CI / build + unit (pull_request) Successful in 42s
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:
+30
-3
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user