diff --git a/AGENTS.md b/AGENTS.md index 3780a46..573b995 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,5 +27,5 @@ - 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`. -- 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 ` (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 `; `#rss tags` (all distinct tags); `#rss sources`; `#rss get ` (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. +- 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 a reply string — or an array of lines, sent as one message per line through the same routing — 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 ` (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 `; `#rss tags` (all distinct tags); `#rss sources`; `#rss get ` (LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line, `: ` with ≤20-char header, no intro/outro/numbering; reply parsed into items — drop empty/separator-less lines, cap at 5, fewer than 3 → send what we got (no retry), zero → no message; each item sent via its own respond() threaded to the trigger packet id, per-line backstop `fitOneLine()` hard-cuts >185-char lines at a word boundary near 180 so every item fits one packet; 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 / 2500 summary headroom). - 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`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a88669..8651679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v0.21.0 — 2026-09-25 19:59 + +- `#rss get ` now replies with **3–5 SEPARATE mesh messages** instead of one small summary (owner request: the single-message summary was too small). Each item is its own packet, so no fragmentation — and each rides the trigger's channel threaded as a quote of the command packet. +- New summarizer prompt asks for exactly 3-5 lines, one item per line, format `: ` with a ≤20-char header (e.g. `Culture: Museum opens new wing`), no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom — see v0.20.1). +- The LLM reply is parsed into items: split on newlines, trim, drop empty lines and lines without a `:` header separator, cap at 5. Fewer than 3 valid lines → send what we got (no failure, no retry — the model is non-deterministic); zero valid lines → no message sent. +- Per-item backstop replaces the old whole-reply cut: any single line >185 chars is hard-cut at a word boundary near 180 (`fitOneLine`), so every item fits one packet even if the model ignores the prompt. `fitOnePacket()` (whole-reply ≤190) is gone — it no longer applies to joined text. +- CommandService now sends array replies line by line: each element goes through the same routing as a single reply (`respond(id, line)` threaded to the trigger packet id; legacy sendText fallback per line when there is no packet id). A failing send stops the remaining lines (warn log), like any failed command. +- LLM failure path unchanged: single `summary failed` reply via respond(). `#rss get #tag` gets the same multi-message treatment automatically (shared summary path — covered by tests). All other subcommands unchanged. +- Tests: 158 → 165 — prompt shape + maxTokens asserted on the new call, 5-item cap, invalid-line filtering, fewer-than-3 fallback, per-line over-length cut through `handle()`, direct `parseSummaryItems`/`fitOneLine` specs, and CommandService array-reply routing (threaded respond ×N to one packet id; legacy sendText fallback). + ## 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. diff --git a/package.json b/package.json index 96931fe..feb67d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.20.1", + "version": "0.21.0", "description": "", "author": "", "private": true, diff --git a/src/commands/command.service.spec.ts b/src/commands/command.service.spec.ts index 95e0738..749a679 100644 --- a/src/commands/command.service.spec.ts +++ b/src/commands/command.service.spec.ts @@ -114,6 +114,30 @@ describe('CommandService.onRx', () => { expect(mesh.respond).toHaveBeenCalledWith(5, 'up from 99'); }); + it('sends each line of an array reply as its own threaded respond() to the same packet id', async () => { + const [svc, mesh] = makeService(); + svc.register('multi', () => ['one: first item', 'two: second item']); + await svc.onRx(2, '#multi', 42, 9); + expect(mesh.respond).toHaveBeenCalledTimes(2); + expect(mesh.respond.mock.calls[0]).toEqual([9, 'one: first item']); + expect(mesh.respond.mock.calls[1]).toEqual([9, 'two: second item']); + expect(mesh.sendText).not.toHaveBeenCalled(); + }); + + it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => { + const [svc, mesh] = makeService(); + svc.register('multi', () => ['one: first item', 'two: second item']); + await svc.onRx(2, '#multi', 42); // no id → legacy path on COMMAND_CHANNEL, DM back to the sender + expect(mesh.sendText).toHaveBeenCalledTimes(2); + expect(mesh.sendText.mock.calls[0]).toEqual([ + 42, + 'one: first item', + undefined, + 2, + ]); + expect(mesh.respond).not.toHaveBeenCalled(); + }); + it('swallows a failing handler (a bad command must not crash the app)', async () => { const [svc, mesh] = makeService(); svc.register('boom', () => { diff --git a/src/commands/command.service.ts b/src/commands/command.service.ts index 673ad50..f174cb4 100644 --- a/src/commands/command.service.ts +++ b/src/commands/command.service.ts @@ -14,10 +14,10 @@ export interface CommandContext { id?: number; } -/** A command handler: returns the reply text to send back, or void/undefined for no reply. */ +/** A command handler: returns the reply text(s) to send back — an array sends one message per line — or void/undefined for no reply. */ export type CommandHandler = ( ctx: CommandContext, -) => string | undefined | Promise; +) => string | string[] | undefined | Promise; @Injectable() export class CommandService { @@ -83,17 +83,20 @@ export class CommandService { try { const reply = await handler({ text: trimmed, from, id }); if (reply === undefined || reply === null) return; // void = no reply - if (id !== undefined && this.mesh) { - // respond() auto-routes the reply: rides the original record's channel with replyId = id (threaded quote) - await this.mesh.respond(id, reply); - } else { - // defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL - await this.mesh?.sendText( - from ?? BROADCAST_ADDR, - reply, - undefined, - this.commandChannel, - ); + // an array reply sends one message per line, all threaded to the same trigger packet id + for (const line of Array.isArray(reply) ? reply : [reply]) { + if (id !== undefined && this.mesh) { + // respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote) + await this.mesh.respond(id, line); + } else { + // defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL + await this.mesh?.sendText( + from ?? BROADCAST_ADDR, + line, + undefined, + this.commandChannel, + ); + } } } catch (err) { this.logger.warn( diff --git a/src/rss/rss.service.spec.ts b/src/rss/rss.service.spec.ts index c2860ed..9513c8d 100644 --- a/src/rss/rss.service.spec.ts +++ b/src/rss/rss.service.spec.ts @@ -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 { fitOnePacket, RssService } from './rss.service.js'; +import { fitOneLine, parseSummaryItems, RssService } from './rss.service.js'; const FEED_A = ` Alpha headline @@ -149,40 +149,79 @@ describe('RssService.handle', () => { expect(await svc.handle(ctx('#rss sources'))).toBe('Aye, Bee'); }); - it('get : summarizes the latest titles via chat with a bounded maxTokens', async () => { + it('get : summarizes via chat with a bounded maxTokens and replies with headered lines', async () => { stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] }); fetchMock.mockResolvedValueOnce(feedResponse(FEED_A)); - llm.chat.mockResolvedValueOnce('Alpha and beta happened.'); + llm.chat.mockResolvedValueOnce( + 'Chips: EU funds foundries.\nPark: Riverfront expansion approved.', + ); const reply = await svc.handle(ctx('#rss get myfeed')); - expect(reply).toBe('Alpha and beta happened.'); + expect(reply).toEqual([ + 'Chips: EU funds foundries.', + 'Park: Riverfront expansion approved.', + ]); const [messages, opts] = llm.chat.mock.calls[0]; // 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('exactly 3-5 lines'); + expect(prompt).toContain(': '); + expect(prompt).toContain('at most 20 characters'); + expect(prompt).toContain('No intro, no outro, no numbering'); expect(prompt).toContain('Alpha headline'); expect(prompt).toContain('Beta headline'); }); - it('get : cuts an over-length LLM reply at bullet boundaries so it fits one packet', async () => { + it('get : caps items at 5', 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)) + const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] + .map((c) => `${c}: item ${c}`) .join('\n'); - llm.chat.mockResolvedValueOnce(tooLong); + llm.chat.mockResolvedValueOnce(seven); 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); + expect(reply).toEqual([ + 'a: item a', + 'b: item b', + 'c: item c', + 'd: item d', + 'e: item e', + ]); + }); + + it('get : drops empty and separator-less lines, sends what remains when fewer than 3 are valid', async () => { + stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] }); + fetchMock.mockResolvedValueOnce(feedResponse(FEED_A)); + llm.chat.mockResolvedValueOnce( + 'Chips: EU funds foundries.\n\nJust a sentence without a header\n \nPark: expansion approved.', + ); + + const reply = await svc.handle(ctx('#rss get myfeed')); + + expect(reply).toEqual([ + 'Chips: EU funds foundries.', + 'Park: expansion approved.', + ]); + }); + + it('get : hard-cuts an over-length line at a word boundary so each item fits one packet', async () => { + stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] }); + fetchMock.mockResolvedValueOnce(feedResponse(FEED_A)); + const longLine = `Chips: ${'word '.repeat(40)}`; // 207 chars — over the per-line cap + llm.chat.mockResolvedValueOnce(`${longLine}\nPark: short line.`); + + const reply = await svc.handle(ctx('#rss get myfeed')); + + expect(Array.isArray(reply)).toBe(true); + const [cut, ok] = reply as string[]; + expect(cut.length).toBeLessThanOrEqual(180); + expect(longLine.startsWith(cut)).toBe(true); // prefix — cut at a word boundary, not mid-word + expect(cut.endsWith(' ')).toBe(false); + expect(ok).toBe('Park: short line.'); // other lines untouched }); it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => { @@ -193,11 +232,11 @@ describe('RssService.handle', () => { .mockResolvedValueOnce(feedResponse(FEED_A)) // A answers (Alpha, Beta) .mockRejectedValueOnce(new Error('down')); // B is dead - llm.chat.mockResolvedValueOnce('Only alpha survived.'); + llm.chat.mockResolvedValueOnce('Chips: only alpha survived.'); const reply = await svc.handle(ctx('#rss get #tech')); // B's fetch rejected, yet the summary still went out from A's titles alone - expect(reply).toBe('Only alpha survived.'); + expect(reply).toEqual(['Chips: only alpha survived.']); expect(llm.chat).toHaveBeenCalledTimes(1); const content = llm.chat.mock.calls[0][0][0].content; expect(content).toContain('Alpha headline'); @@ -210,9 +249,15 @@ describe('RssService.handle', () => { .mockResolvedValueOnce(feedResponse(FEED_A)) // A → Alpha, Beta .mockResolvedValueOnce(feedResponse(FEED_B)); // B → Gamma - llm.chat.mockResolvedValueOnce('all three.'); - await svc.handle(ctx('#rss get #tech')); + llm.chat.mockResolvedValueOnce( + 'Alpha: first headline.\nGamma: second headline.', + ); + const reply = await svc.handle(ctx('#rss get #tech')); + expect(reply).toEqual([ + 'Alpha: first headline.', + 'Gamma: second headline.', + ]); const content = llm.chat.mock.calls[0][0][0].content; expect(content).toContain('Alpha headline'); expect(content).toContain('Gamma headline'); // proves B's feed was collected too @@ -227,25 +272,46 @@ 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); +describe('parseSummaryItems (per-line items for multi-message summaries)', () => { + it('keeps lines with a header separator, drops empty and separator-less ones', () => { + expect( + parseSummaryItems( + 'Chips: EU funds foundries.\n\nNo header here\n \nPark: expansion approved.', + ), + ).toEqual(['Chips: EU funds foundries.', 'Park: expansion approved.']); }); - 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)) + it('caps at 5 items', () => { + const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] + .map((c) => `${c}: item`) .join('\n'); - expect(fitOnePacket(five).split('\n')).toHaveLength(4); - expect(fitOnePacket(five).length).toBeLessThanOrEqual(190); + expect(parseSummaryItems(seven)).toHaveLength(5); + expect(parseSummaryItems(seven)[4]).toBe('e: item'); }); - 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)); + it('returns [] when nothing is valid', () => { + expect(parseSummaryItems('no headers at all\n')).toEqual([]); + }); +}); + +describe('fitOneLine (one mesh packet per line)', () => { + it('passes through lines that already fit, including exactly 185 chars', () => { + const short = 'Chips: EU funds foundries.'; + expect(fitOneLine(short)).toBe(short); + const exact = 'x'.repeat(185); + expect(fitOneLine(exact)).toBe(exact); + }); + + it('cuts over-long lines at a word boundary near 180', () => { + const long = `Chips: ${'word '.repeat(40)}`; // 207 chars + const cut = fitOneLine(long); + expect(cut.length).toBeLessThanOrEqual(180); + expect(long.startsWith(cut)).toBe(true); // prefix — not mid-word + expect(cut.endsWith(' ')).toBe(false); + }); + + it('hard-cuts at 180 when there is no word boundary in reach', () => { + const long = 'w'.repeat(250); + expect(fitOneLine(long)).toBe('w'.repeat(180)); }); }); diff --git a/src/rss/rss.service.ts b/src/rss/rss.service.ts index 9b834e4..c49fe85 100644 --- a/src/rss/rss.service.ts +++ b/src/rss/rss.service.ts @@ -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 { + /** Entry point for the `#rss` prefix — always replies (errors included); `get` may reply with several lines. */ + async handle(ctx: CommandContext): Promise { 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 ` or `#rss get #`. */ - private async get(target: string): Promise { + /** `#rss get ` or `#rss get #` — replies with 3-5 separate one-packet lines. */ + private async get(target: string): Promise { if (!target) return 'usage: #rss get '; 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 ": " 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. */