Files
penguin-harness/packages/core/src/session-title.ts
T
Yaowei Zheng d4faee3a1e Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)
Branch-length batch covering tooling, the model layer, the Web App and the public
surfaces. Highlights:

- Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each
  change touches, with a root CHANGELOG.md holding one line per release.
- Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a
  lock and keeps `pnpm install` current; `pnpm dev` runs server+web together.
- AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity`
  object in place of item-level `signature`/`phase`, threaded verbatim through Trace,
  replay and resume; malformed classification adapted to the new error types.
- Model layer: a model is always referenced by an explicit `(provider, model_id)` pair.
  The provider is never inferred, guessed or defaulted -- both the catalog inference and
  the unique-match config resolution are gone, and CLI, SDK, server routes and
  run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen
  Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group.
- Web App: catalog preset sync and per-group speed test on the Models page, positional
  slash commands, a markdown renderer, skill-library update reminders, and a vertically
  centred draft page whose upward menus size themselves to the room available.
- Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed
  benchmark results for both suites, and the demo videos playing on the landing page.

Includes the fixes from a full review of the branch: 23 confirmed findings, among them a
provider-inference bug that could send one vendor's API key to another vendor's endpoint,
and an Escape handler that destroyed the composer's contents unrecoverably.

Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and
pnpm format:check clean, Playwright e2e 14/14.
2026-07-21 17:43:31 +08:00

158 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Session title generation: an **out-of-band, one-off request** that generates
* a short title from the first-turn conversation text.
*
* Called by `session.generateTitle()`: sends one request using the bare LLM for the session's
* Model (no tools, no system prompt, thinking off), without writing history or Trace. Material
* defaults to what the Session self-captures during run (see session.ts); this module is only
* responsible for the prompt format, driving the one-off request, and sanitizing the result —
* when to generate a title and where to store it is decided by the host (Web server / CLI).
*/
import { userText } from "./omnimessage/index.js";
import type {
OmniMessage,
TextPayload,
TokenCounts,
TokenUsagePayload,
} from "./omnimessage/index.js";
import type { LLMInterface } from "./interfaces.js";
/** Cap on conversation text spliced into the title request (user/model each truncated separately, to control cost). */
const EXCERPT_MAX_CHARS = 2000;
/** Cap on title length (fallback truncation for when the model occasionally ignores the constraint). */
const TITLE_MAX_CHARS = 30;
/**
* Special message markers that must never leak into a title. These are machine-inserted
* XML-ish blocks (a skill invocation wraps the body in `<use_skills>…</use_skills>`, a
* subagent handoff / scheduled task prepend their own blocks) — meaningful to the runtime,
* noise in a title. The list is a fixed allowlist so ordinary angle-bracket text (e.g. a
* user pasting `<div>`) is left untouched.
*/
const MARKER_TAGS = ["use_skills", "handoff_from", "scheduled_task"];
/**
* Strips machine-inserted marker blocks (see MARKER_TAGS) from conversation text so titles are
* built from the human-meaningful body only — both the material sent to the model and the
* fallback derived from the raw first message. Removes the paired `<tag>…</tag>` block (any
* inner content, across lines) and any stray unpaired `<tag>` / `</tag>` left behind.
*/
export function stripConversationMarkers(text: string): string {
let out = text;
for (const tag of MARKER_TAGS) {
out = out
.replace(new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, "g"), "")
.replace(new RegExp(`</?${tag}>`, "g"), "");
}
return out.trim();
}
export interface SessionTitleResult {
/** The sanitized title; null when material is insufficient, the request fails, or the output is empty. */
title: string | null;
/** Token consumption for this request (accumulated token_usage.request); null if no request occurred or there's no usage. */
usage: TokenCounts | null;
}
/**
* Assembles the title-generation Prompt (exported for host/test assertion use). Uses English
* instructions to avoid polluting the title's language, and requires the **title to be in the
* same language as the conversation** (English conversation gets an English title, Chinese
* conversation gets a Chinese title); when assistant material is empty, it relies on the user
* request alone.
*/
export function buildTitlePrompt(userExcerpt: string, assistantExcerpt: string): string {
const clip = (s: string) => (s.length > EXCERPT_MAX_CHARS ? s.slice(0, EXCERPT_MAX_CHARS) : s);
const lines = [
"Generate a concise title for the conversation below.",
"Rules:",
"- Write the title in the SAME language the user is using.",
"- Keep it short: at most 6 words, or ~16 characters for CJK.",
"- Output ONLY the title text — no quotes, no trailing punctuation, no explanation.",
"- Answer immediately — do not think aloud or produce chain-of-thought.",
"",
"[User]",
clip(userExcerpt),
];
if (assistantExcerpt.trim()) {
lines.push("", "[Assistant]", clip(assistantExcerpt));
}
// The trailing empty think block makes many reasoning models treat their thinking phase
// as already closed, so the one-off request spends its budget on the title itself.
lines.push("", "<think></think>");
return lines.join("\n");
}
/** Sanitizes model output into a title: strips any leaked marker blocks and leading/trailing quotes/brackets and trailing punctuation (until stable), collapses whitespace, and truncates if too long; returns null for an empty result. */
export function sanitizeTitle(raw: string): string | null {
let t = stripConversationMarkers(raw).replace(/\s+/g, " ").trim();
// Stripping quotes can expose more punctuation underneath (or vice versa), so strip repeatedly until stable.
for (let prev = ""; prev !== t;) {
prev = t;
t = t
.replace(/^["'“”‘’「」『』《》〈〉【】()()\s]+/, "")
.replace(/["'“”‘’「」『』《》〈〉【】()()\s]+$/, "")
.replace(/[。..!!??;;,,、::]+$/, "")
.trim();
}
if (!t) return null;
return t.length > TITLE_MAX_CHARS ? t.slice(0, TITLE_MAX_CHARS) : t;
}
/**
* Drives a single title-generation request: collects model text and token_usage, and resolves
* based on the outcome. Generation only requires user material (assistant material may be
* empty — a pure tool-only turn can still get a title); no request is sent if user material is
* empty; `title` is null if the request doesn't complete (any usage already produced is still
* returned).
*/
export async function generateTitleWithLLM(
llm: LLMInterface,
args: { userText: string; assistantText: string; signal?: AbortSignal },
): Promise<SessionTitleResult> {
// Strip machine markers before the model ever sees the material, so a skill-invocation
// block (or handoff / scheduled-task marker) can't bleed into the generated title.
const userMaterial = stripConversationMarkers(args.userText);
const assistantMaterial = stripConversationMarkers(args.assistantText);
if (!userMaterial.trim()) {
return { title: null, usage: null };
}
const prompt = buildTitlePrompt(userMaterial, assistantMaterial);
const gen = llm.streamGenerate({
newMessages: [userText(prompt)],
...(args.signal ? { signal: args.signal } : {}),
});
let collected = "";
let usage: TokenCounts | null = null;
for (;;) {
const step = await gen.next();
if (step.done) {
if (step.value.status !== "completed") return { title: null, usage };
break;
}
const msg = step.value;
if (isAssistantText(msg)) collected += msg.payload.text;
if (isTokenUsage(msg)) {
const r = msg.payload.request;
usage = usage
? {
cache_read: usage.cache_read + r.cache_read,
cache_write: usage.cache_write + r.cache_write,
output: usage.output + r.output,
total: usage.total + r.total,
}
: { ...r };
}
}
return { title: sanitizeTitle(collected), usage };
}
function isAssistantText(msg: OmniMessage): msg is OmniMessage<TextPayload> {
const payload = msg.payload as { type?: string; role?: string };
return msg.type === "model_msg" && payload.type === "text" && payload.role === "assistant";
}
function isTokenUsage(msg: OmniMessage): msg is OmniMessage<TokenUsagePayload> {
return msg.type === "event_msg" && (msg.payload as { type?: string }).type === "token_usage";
}