Send #rss get summaries as 3-5 separate mesh messages (v0.21.0)
CI / build + unit (pull_request) Successful in 1m20s
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.
This commit is contained in:
+30
-25
@@ -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);
|
||||
}
|
||||
// non-bulleted paragraph: a hard cut still beats an empty reply
|
||||
return kept.length ? kept.join('\n') : reply.slice(0, MAX_REPLY_CHARS);
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
||||
Reference in New Issue
Block a user