feat: added tiny-title generation core with worker flow and model specs
- Added `providers.tinyModel` with an `online` default and UI schema metadata; documented tiny-title updates in changelogs. - Added tiny-title system prompt and title-text helpers to truncate input, wrap `<user-message>`, and normalize output. - Added tiny-title model registry, local model specs, key validation, and cache-path helper. - Added tiny-title transport/client/worker flow with spawn fallback, queued requests, ping checks, and graceful close. - Updated `generateSessionTitle` to route by tiny-title model and race local generation against delayed online fallback.
This commit is contained in:
@@ -4,15 +4,20 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added an opt-in Providers → Tiny Model setting for session titles, with five local CPU transformers.js models racing against delayed `pi/smol` fallback while the default remains online-only.
|
||||
- Added a persistent live agent roster pinned below the editor (focus it with `Ctrl+S` or `Alt+Down`), including view-as switching into delegated agent sessions with human-readable delegate names and UI pinning to suppress idle reaping while viewed. The roster stays hidden until at least one delegated agent exists and releases focus back to the editor once the last one is gone.
|
||||
- Recorded the originating session ID alongside each prompt in `history.db` (new `session_id` column, surfaced as `HistoryEntry.sessionId`), so recalled prompts can be traced back to the session they came from. Existing history databases gain the column automatically on next launch.
|
||||
- Added compact inline TUI renderers for the `retain`, `recall`, and `reflect` memory tools. `retain` now shows one themed bullet line per stored item (truncated to width) under a status header with the stored/queued count, and `recall`/`reflect` collapse to a single query header (recall reports the match count and hides recalled memories until expanded) instead of dumping the raw JSON argument tree.
|
||||
- Added a randomly picked tip beneath the welcome screen, sourced from an embedded `tips.txt` (one tip per line). The line is italicized with a purple `Tip:` label and a dimmed light-blue body, and the tip is chosen once per welcome instance so intro-animation and LSP re-renders don't shuffle it.
|
||||
- Added a Mnemosyne-only `memory_edit` agent tool for updating, forgetting, or invalidating recalled memories by id, and added `/memory stats` plus `/memory diagnose` slash commands for backend maintenance visibility.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `irc` to treat the attached human as a first-class `User` peer, merging human prompts into `irc call User` with optional structured question payloads and adding `/dm <agent> <message>` for user-to-agent routing without switching views.
|
||||
- Changed the `--resume` session picker (and the in-session resume selector) to also rank sessions by prompt-history matches from `history.db`, not just the session-list metadata. Because the session list only indexes the first 4KB of each file, this surfaces sessions by prompts typed deep into long conversations. Sessions matched by both signals lead, then metadata-only matches, then history-only matches — no metadata match is dropped.
|
||||
- Changed the `task` tool's streaming call preview to list each dispatched agent's `id` and UI description as a tree instead of a bare `N agents` count, so the individual agents are visible while the tool-call arguments are still streaming. The collapsed view caps at 12 entries (`… N more agents`); the expanded view shows all.
|
||||
- Changed Mnemosyne `recall` tool output to include memory ids for explicit recall results so agents can target `memory_edit`; auto-injected memory context and `reflect` remain id-free.
|
||||
- Changed the system prompt to advertise `memory://root` only when the local memory backend is active.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
|
||||
import { TASK_SIMPLE_MODES } from "../task/simple-mode";
|
||||
import { getThinkingLevelMetadata } from "../thinking";
|
||||
import { TINY_TITLE_MODEL_OPTIONS, TINY_TITLE_MODEL_VALUES } from "../title/tiny-models";
|
||||
import { EDIT_MODES } from "../utils/edit-mode";
|
||||
|
||||
/** Unified settings schema - single source of truth for all settings.
|
||||
@@ -2896,6 +2897,17 @@ export const SETTINGS_SCHEMA = {
|
||||
],
|
||||
},
|
||||
},
|
||||
"providers.tinyModel": {
|
||||
type: "enum",
|
||||
values: TINY_TITLE_MODEL_VALUES,
|
||||
default: "online",
|
||||
ui: {
|
||||
tab: "providers",
|
||||
label: "Tiny Model",
|
||||
description: "Session-title model: online pi/smol or an opt-in local CPU model",
|
||||
options: TINY_TITLE_MODEL_OPTIONS,
|
||||
},
|
||||
},
|
||||
|
||||
"providers.kimiApiFormat": {
|
||||
type: "enum",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
You generate concise terminal session titles.
|
||||
|
||||
Input is one user message inside `<user-message>` tags.
|
||||
|
||||
Return one specific 3-6 word title.
|
||||
Continue the assistant response after `<title>` and close it with `</title>`.
|
||||
|
||||
NEVER include quotes, punctuation, markdown, commentary, or a second line.
|
||||
@@ -0,0 +1,100 @@
|
||||
export const ONLINE_TINY_TITLE_MODEL_KEY = "online";
|
||||
|
||||
export interface TinyTitleLocalModelSpec {
|
||||
key: string;
|
||||
repo: string;
|
||||
dtype: "q4";
|
||||
label: string;
|
||||
description: string;
|
||||
contextNote: string;
|
||||
}
|
||||
|
||||
export const TINY_TITLE_LOCAL_MODELS = [
|
||||
{
|
||||
key: "lfm2-350m",
|
||||
repo: "onnx-community/LFM2-350M-ONNX",
|
||||
dtype: "q4",
|
||||
label: "LFM2 350M",
|
||||
description: "Recommended local model; best speed/quality balance, about 212 MB cached.",
|
||||
contextNote: "Best local default from the CPU title-generation spike.",
|
||||
},
|
||||
{
|
||||
key: "qwen3-0.6b",
|
||||
repo: "onnx-community/Qwen3-0.6B-ONNX",
|
||||
dtype: "q4",
|
||||
label: "Qwen3 0.6B",
|
||||
description: "Most robust local option; slower first load, about 500 MB cached.",
|
||||
contextNote: "Use when title quality matters more than local startup cost.",
|
||||
},
|
||||
{
|
||||
key: "gemma-270m",
|
||||
repo: "onnx-community/gemma-3-270m-it-ONNX",
|
||||
dtype: "q4",
|
||||
label: "Gemma 270M",
|
||||
description: "Smallest viable local option; lower quality, lowest cache footprint.",
|
||||
contextNote: "Use on constrained machines that still need local titles.",
|
||||
},
|
||||
{
|
||||
key: "qwen2.5-0.5b",
|
||||
repo: "onnx-community/Qwen2.5-0.5B-Instruct",
|
||||
dtype: "q4",
|
||||
label: "Qwen2.5 0.5B",
|
||||
description: "Balanced local fallback; moderate quality and cache footprint.",
|
||||
contextNote: "Useful when Qwen3 is too heavy but Gemma quality is insufficient.",
|
||||
},
|
||||
{
|
||||
key: "lfm2-700m",
|
||||
repo: "onnx-community/LFM2-700M-ONNX",
|
||||
dtype: "q4",
|
||||
label: "LFM2 700M",
|
||||
description: "Highest-quality local option; larger and slower than LFM2 350M.",
|
||||
contextNote: "Use when local title quality is preferred over startup cost.",
|
||||
},
|
||||
] as const satisfies readonly TinyTitleLocalModelSpec[];
|
||||
|
||||
export const TINY_TITLE_MODEL_VALUES = [
|
||||
ONLINE_TINY_TITLE_MODEL_KEY,
|
||||
"lfm2-350m",
|
||||
"qwen3-0.6b",
|
||||
"gemma-270m",
|
||||
"qwen2.5-0.5b",
|
||||
"lfm2-700m",
|
||||
] as const;
|
||||
|
||||
export type TinyTitleModelKey = (typeof TINY_TITLE_MODEL_VALUES)[number];
|
||||
export type TinyTitleLocalModelKey = (typeof TINY_TITLE_LOCAL_MODELS)[number]["key"];
|
||||
|
||||
type MissingTinyTitleModelValue = Exclude<
|
||||
typeof ONLINE_TINY_TITLE_MODEL_KEY | TinyTitleLocalModelKey,
|
||||
TinyTitleModelKey
|
||||
>;
|
||||
type ExtraTinyTitleModelValue = Exclude<TinyTitleModelKey, typeof ONLINE_TINY_TITLE_MODEL_KEY | TinyTitleLocalModelKey>;
|
||||
const TINY_TITLE_MODEL_VALUES_MATCH_REGISTRY: MissingTinyTitleModelValue extends never
|
||||
? ExtraTinyTitleModelValue extends never
|
||||
? true
|
||||
: never
|
||||
: never = true;
|
||||
void TINY_TITLE_MODEL_VALUES_MATCH_REGISTRY;
|
||||
|
||||
export const TINY_TITLE_MODEL_OPTIONS = [
|
||||
{
|
||||
value: ONLINE_TINY_TITLE_MODEL_KEY,
|
||||
label: "Online (pi/smol)",
|
||||
description: "Current online title generation path; no local model download or CPU inference.",
|
||||
},
|
||||
...TINY_TITLE_LOCAL_MODELS.map(model => ({
|
||||
value: model.key,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
})),
|
||||
] satisfies ReadonlyArray<{ value: TinyTitleModelKey; label: string; description: string }>;
|
||||
|
||||
export function isTinyTitleLocalModelKey(value: string): value is TinyTitleLocalModelKey {
|
||||
return TINY_TITLE_LOCAL_MODELS.some(model => model.key === value);
|
||||
}
|
||||
|
||||
export function getTinyTitleModelSpec(key: TinyTitleLocalModelKey): (typeof TINY_TITLE_LOCAL_MODELS)[number] {
|
||||
const spec = TINY_TITLE_LOCAL_MODELS.find(model => model.key === key);
|
||||
if (!spec) throw new Error(`Unknown tiny title model: ${key}`);
|
||||
return spec;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { isCompiledBinary, logger } from "@oh-my-pi/pi-utils";
|
||||
import { isTinyTitleLocalModelKey } from "./tiny-models";
|
||||
import type { TinyTitleWorkerInbound, TinyTitleWorkerOutbound } from "./tiny-title-protocol";
|
||||
|
||||
interface WorkerHandle {
|
||||
mode: "worker" | "inline";
|
||||
send(message: TinyTitleWorkerInbound): void;
|
||||
onMessage(handler: (message: TinyTitleWorkerOutbound) => void): () => void;
|
||||
onError(handler: (error: Error) => void): () => void;
|
||||
terminate(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve(title: string | null): void;
|
||||
}
|
||||
|
||||
const SMOKE_TEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export function createTinyTitleWorker(): Worker {
|
||||
return isCompiledBinary()
|
||||
? new Worker("./packages/coding-agent/src/title/tiny-title-worker.ts", { type: "module" })
|
||||
: new Worker(new URL("./tiny-title-worker.ts", import.meta.url).href, { type: "module" });
|
||||
}
|
||||
|
||||
function wrapBunWorker(worker: Worker): WorkerHandle {
|
||||
return {
|
||||
mode: "worker",
|
||||
send(message) {
|
||||
worker.postMessage(message);
|
||||
},
|
||||
onMessage(handler) {
|
||||
const wrap = (event: MessageEvent): void => handler(event.data as TinyTitleWorkerOutbound);
|
||||
worker.addEventListener("message", wrap);
|
||||
return () => worker.removeEventListener("message", wrap);
|
||||
},
|
||||
onError(handler) {
|
||||
const wrap = (event: ErrorEvent): void => {
|
||||
handler(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
|
||||
};
|
||||
worker.addEventListener("error", wrap);
|
||||
return () => worker.removeEventListener("error", wrap);
|
||||
},
|
||||
async terminate() {
|
||||
worker.terminate();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
|
||||
const listeners = new Set<(message: TinyTitleWorkerOutbound) => void>();
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const emit = (message: TinyTitleWorkerOutbound): void => {
|
||||
for (const listener of listeners) listener(message);
|
||||
};
|
||||
return {
|
||||
mode: "inline",
|
||||
send(message) {
|
||||
queueMicrotask(() => {
|
||||
if (message.type === "ping") {
|
||||
emit({ type: "pong", id: message.id });
|
||||
return;
|
||||
}
|
||||
if (message.type === "close") {
|
||||
emit({ type: "closed" });
|
||||
return;
|
||||
}
|
||||
emit({ type: "error", id: message.id, error: errorMessage });
|
||||
});
|
||||
},
|
||||
onMessage(handler) {
|
||||
listeners.add(handler);
|
||||
return () => listeners.delete(handler);
|
||||
},
|
||||
onError() {
|
||||
return () => {};
|
||||
},
|
||||
async terminate() {
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function spawnTinyTitleWorker(): WorkerHandle {
|
||||
try {
|
||||
return wrapBunWorker(createTinyTitleWorker());
|
||||
} catch (error) {
|
||||
logger.warn("Tiny title Worker spawn failed; local titles disabled", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return spawnInlineUnavailableWorker(error);
|
||||
}
|
||||
}
|
||||
|
||||
function logWorkerMessage(message: Extract<TinyTitleWorkerOutbound, { type: "log" }>): void {
|
||||
if (message.level === "debug") logger.debug(message.msg, message.meta);
|
||||
else if (message.level === "warn") logger.warn(message.msg, message.meta);
|
||||
else logger.error(message.msg, message.meta);
|
||||
}
|
||||
|
||||
export class TinyTitleClient {
|
||||
#worker: WorkerHandle | null = null;
|
||||
#unsubscribeMessage: (() => void) | null = null;
|
||||
#unsubscribeError: (() => void) | null = null;
|
||||
#pending = new Map<string, PendingRequest>();
|
||||
#nextRequestId = 0;
|
||||
|
||||
async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null> {
|
||||
if (!isTinyTitleLocalModelKey(modelKey)) return null;
|
||||
if (signal?.aborted) return null;
|
||||
|
||||
try {
|
||||
const worker = this.#ensureWorker();
|
||||
const id = String(++this.#nextRequestId);
|
||||
const { promise, resolve } = Promise.withResolvers<string | null>();
|
||||
const pending: PendingRequest = { resolve };
|
||||
this.#pending.set(id, pending);
|
||||
const abort = (): void => {
|
||||
if (!this.#pending.delete(id)) return;
|
||||
resolve(null);
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
worker.send({ type: "generate", id, modelKey, message });
|
||||
return await promise;
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
this.#pending.delete(id);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("tiny-title: local generation failed", {
|
||||
modelKey,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
const worker = this.#worker;
|
||||
this.#worker = null;
|
||||
this.#unsubscribeMessage?.();
|
||||
this.#unsubscribeMessage = null;
|
||||
this.#unsubscribeError?.();
|
||||
this.#unsubscribeError = null;
|
||||
for (const pending of this.#pending.values()) pending.resolve(null);
|
||||
this.#pending.clear();
|
||||
if (!worker) return;
|
||||
try {
|
||||
worker.send({ type: "close" });
|
||||
} catch {
|
||||
// Worker may already be gone.
|
||||
}
|
||||
await worker.terminate().catch(() => undefined);
|
||||
}
|
||||
|
||||
#ensureWorker(): WorkerHandle {
|
||||
if (this.#worker) return this.#worker;
|
||||
const worker = spawnTinyTitleWorker();
|
||||
this.#worker = worker;
|
||||
this.#unsubscribeMessage = worker.onMessage(message => this.#handleMessage(message));
|
||||
this.#unsubscribeError = worker.onError(error => this.#handleWorkerError(error));
|
||||
return worker;
|
||||
}
|
||||
|
||||
#handleMessage(message: TinyTitleWorkerOutbound): void {
|
||||
if (message.type === "log") {
|
||||
logWorkerMessage(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "closed") {
|
||||
void this.terminate();
|
||||
return;
|
||||
}
|
||||
if (message.type === "pong") return;
|
||||
|
||||
const pending = this.#pending.get(message.id);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(message.id);
|
||||
if (message.type === "title") {
|
||||
pending.resolve(message.title);
|
||||
return;
|
||||
}
|
||||
logger.debug("tiny-title: worker returned error", { error: message.error });
|
||||
pending.resolve(null);
|
||||
}
|
||||
|
||||
#handleWorkerError(error: Error): void {
|
||||
logger.warn("tiny-title: worker error", { error: error.message });
|
||||
for (const pending of this.#pending.values()) pending.resolve(null);
|
||||
this.#pending.clear();
|
||||
void this.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
export const tinyTitleClient = new TinyTitleClient();
|
||||
|
||||
export async function shutdownTinyTitleClient(): Promise<void> {
|
||||
await tinyTitleClient.terminate();
|
||||
}
|
||||
|
||||
export async function smokeTestTinyTitleWorker({
|
||||
timeoutMs = SMOKE_TEST_TIMEOUT_MS,
|
||||
}: {
|
||||
timeoutMs?: number;
|
||||
} = {}): Promise<void> {
|
||||
const worker = createTinyTitleWorker();
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
const timer = setTimeout(() => reject(new Error(`tiny title worker did not pong within ${timeoutMs}ms`)), timeoutMs);
|
||||
worker.onmessage = (event: MessageEvent<TinyTitleWorkerOutbound>) => {
|
||||
const message = event.data;
|
||||
if (message.type === "pong") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`tiny title worker: expected pong, got ${JSON.stringify(message)}`));
|
||||
};
|
||||
worker.onerror = (event: ErrorEvent) => {
|
||||
reject(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
|
||||
};
|
||||
try {
|
||||
worker.postMessage({ type: "ping", id: "smoke" } satisfies TinyTitleWorkerInbound);
|
||||
await promise;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TinyTitleLocalModelKey } from "./tiny-models";
|
||||
|
||||
export type TinyTitleWorkerInbound =
|
||||
| { type: "ping"; id: string }
|
||||
| { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
|
||||
| { type: "close" };
|
||||
|
||||
export type TinyTitleWorkerOutbound =
|
||||
| { type: "pong"; id: string }
|
||||
| { type: "title"; id: string; title: string | null }
|
||||
| { type: "error"; id: string; error: string }
|
||||
| { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
|
||||
| { type: "closed" };
|
||||
|
||||
export interface TinyTitleTransport {
|
||||
send(message: TinyTitleWorkerOutbound): void;
|
||||
onMessage(handler: (message: TinyTitleWorkerInbound) => void): () => void;
|
||||
close(): void;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import {
|
||||
env,
|
||||
LogLevel,
|
||||
pipeline,
|
||||
StoppingCriteria,
|
||||
type TextGenerationPipeline,
|
||||
type TextGenerationStringOutput,
|
||||
} from "@huggingface/transformers";
|
||||
import { getTinyModelsCacheDir, prompt } from "@oh-my-pi/pi-utils";
|
||||
import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
|
||||
import { getTinyTitleModelSpec, type TinyTitleLocalModelKey } from "./tiny-models";
|
||||
import type { TinyTitleTransport, TinyTitleWorkerInbound, TinyTitleWorkerOutbound } from "./tiny-title-protocol";
|
||||
import { formatTitleUserMessage, normalizeGeneratedTitle } from "./title-text";
|
||||
|
||||
const TITLE_PREFILL = "<title>";
|
||||
const TITLE_CLOSE = "</title>";
|
||||
const TITLE_MAX_NEW_TOKENS = 20;
|
||||
const STOP_DECODE_WINDOW_TOKENS = 32;
|
||||
const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
|
||||
|
||||
env.cacheDir = getTinyModelsCacheDir();
|
||||
env.allowLocalModels = false;
|
||||
env.logLevel = LogLevel.ERROR;
|
||||
|
||||
class StopOnTextCriteria extends StoppingCriteria {
|
||||
#tokenizer: TextGenerationPipeline["tokenizer"];
|
||||
#text: string;
|
||||
|
||||
constructor(tokenizer: TextGenerationPipeline["tokenizer"], text: string) {
|
||||
super();
|
||||
this.#tokenizer = tokenizer;
|
||||
this.#text = text;
|
||||
}
|
||||
|
||||
_call(inputIds: number[][]): boolean[] {
|
||||
return inputIds.map(ids => {
|
||||
const tail = ids.slice(-STOP_DECODE_WINDOW_TOKENS);
|
||||
const text = this.#tokenizer.decode(tail, { skip_special_tokens: false, clean_up_tokenization_spaces: false });
|
||||
return text.includes(this.#text);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pipelines = new Map<TinyTitleLocalModelKey, Promise<TextGenerationPipeline>>();
|
||||
let generateQueue = Promise.resolve();
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
}
|
||||
|
||||
function sendLog(
|
||||
transport: TinyTitleTransport,
|
||||
level: "debug" | "warn" | "error",
|
||||
msg: string,
|
||||
meta?: Record<string, unknown>,
|
||||
): void {
|
||||
transport.send({ type: "log", level, msg, meta });
|
||||
}
|
||||
|
||||
function loadPipeline(
|
||||
modelKey: TinyTitleLocalModelKey,
|
||||
transport: TinyTitleTransport,
|
||||
): Promise<TextGenerationPipeline> {
|
||||
const cached = pipelines.get(modelKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const spec = getTinyTitleModelSpec(modelKey);
|
||||
const startedAt = performance.now();
|
||||
const loaded = pipeline("text-generation", spec.repo, {
|
||||
device: "cpu",
|
||||
dtype: spec.dtype,
|
||||
}).then(
|
||||
generator => {
|
||||
sendLog(transport, "debug", "tiny-title: local model loaded", {
|
||||
modelKey,
|
||||
repo: spec.repo,
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
return generator;
|
||||
},
|
||||
error => {
|
||||
pipelines.delete(modelKey);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
pipelines.set(modelKey, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function buildPrompt(generator: TextGenerationPipeline, message: string): string {
|
||||
const chat = [
|
||||
{ role: "system", content: TINY_TITLE_SYSTEM_PROMPT },
|
||||
{ role: "user", content: formatTitleUserMessage(message) },
|
||||
];
|
||||
const chatTemplateOptions = {
|
||||
add_generation_prompt: true,
|
||||
tokenize: false,
|
||||
enable_thinking: false,
|
||||
};
|
||||
return `${generator.tokenizer.apply_chat_template(chat, chatTemplateOptions)}${TITLE_PREFILL}`;
|
||||
}
|
||||
|
||||
function extractTinyTitle(text: string): string | null {
|
||||
const titleStart = text.lastIndexOf(TITLE_PREFILL);
|
||||
const withoutPrefix = titleStart >= 0 ? text.slice(titleStart + TITLE_PREFILL.length) : text;
|
||||
const closeIndex = withoutPrefix.indexOf(TITLE_CLOSE);
|
||||
const withoutClose = closeIndex >= 0 ? withoutPrefix.slice(0, closeIndex) : withoutPrefix;
|
||||
const tagIndex = withoutClose.indexOf("<");
|
||||
const withoutTag = tagIndex >= 0 ? withoutClose.slice(0, tagIndex) : withoutClose;
|
||||
return normalizeGeneratedTitle(withoutTag);
|
||||
}
|
||||
|
||||
async function generateTitle(
|
||||
transport: TinyTitleTransport,
|
||||
modelKey: TinyTitleLocalModelKey,
|
||||
message: string,
|
||||
): Promise<string | null> {
|
||||
const generator = await loadPipeline(modelKey, transport);
|
||||
const promptText = buildPrompt(generator, message);
|
||||
const output: TextGenerationStringOutput = await generator(promptText, {
|
||||
max_new_tokens: TITLE_MAX_NEW_TOKENS,
|
||||
do_sample: false,
|
||||
return_full_text: false,
|
||||
stopping_criteria: new StopOnTextCriteria(generator.tokenizer, TITLE_CLOSE),
|
||||
});
|
||||
return extractTinyTitle(output[0]?.generated_text ?? "");
|
||||
}
|
||||
|
||||
async function disposePipelines(): Promise<void> {
|
||||
const settled = await Promise.allSettled([...pipelines.values()]);
|
||||
pipelines.clear();
|
||||
await Promise.allSettled(
|
||||
settled.map(result => (result.status === "fulfilled" ? result.value.dispose() : Promise.resolve())),
|
||||
);
|
||||
}
|
||||
|
||||
function handleGenerate(
|
||||
transport: TinyTitleTransport,
|
||||
request: Extract<TinyTitleWorkerInbound, { type: "generate" }>,
|
||||
): void {
|
||||
generateQueue = generateQueue.then(
|
||||
async () => {
|
||||
try {
|
||||
const title = await generateTitle(transport, request.modelKey, request.message);
|
||||
transport.send({ type: "title", id: request.id, title });
|
||||
} catch (error) {
|
||||
transport.send({ type: "error", id: request.id, error: errorText(error) });
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const title = await generateTitle(transport, request.modelKey, request.message);
|
||||
transport.send({ type: "title", id: request.id, title });
|
||||
} catch (error) {
|
||||
transport.send({ type: "error", id: request.id, error: errorText(error) });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function startTinyTitleWorker(transport: TinyTitleTransport): void {
|
||||
transport.onMessage(message => {
|
||||
if (message.type === "ping") {
|
||||
transport.send({ type: "pong", id: message.id });
|
||||
return;
|
||||
}
|
||||
if (message.type === "close") {
|
||||
void disposePipelines().finally(() => {
|
||||
transport.send({ type: "closed" });
|
||||
transport.close();
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleGenerate(transport, message);
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentPort) throw new Error("tiny-title-worker: missing parentPort");
|
||||
|
||||
const port = parentPort;
|
||||
const transport: TinyTitleTransport = {
|
||||
send: (message: TinyTitleWorkerOutbound) => port.postMessage(message),
|
||||
onMessage: handler => {
|
||||
const wrap = (data: unknown): void => handler(data as TinyTitleWorkerInbound);
|
||||
port.on("message", wrap);
|
||||
return () => port.off("message", wrap);
|
||||
},
|
||||
close: () => {
|
||||
try {
|
||||
port.close();
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
startTinyTitleWorker(transport);
|
||||
@@ -0,0 +1,19 @@
|
||||
export const MAX_TITLE_INPUT_CHARS = 2000;
|
||||
|
||||
export function truncateTitleInput(message: string): string {
|
||||
return message.length > MAX_TITLE_INPUT_CHARS ? `${message.slice(0, MAX_TITLE_INPUT_CHARS)}…` : message;
|
||||
}
|
||||
|
||||
export function formatTitleUserMessage(message: string): string {
|
||||
return `<user-message>\n${truncateTitleInput(message)}\n</user-message>`;
|
||||
}
|
||||
|
||||
export function normalizeGeneratedTitle(value: string | null | undefined): string | null {
|
||||
const firstLine = value?.trim().split(/\r?\n/, 1)[0]?.trim();
|
||||
if (!firstLine) return null;
|
||||
const title = firstLine
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.replace(/[.!?]$/, "")
|
||||
.trim();
|
||||
return title || null;
|
||||
}
|
||||
@@ -9,13 +9,16 @@ import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveRoleSelection } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
|
||||
import { ONLINE_TINY_TITLE_MODEL_KEY } from "../title/tiny-models";
|
||||
import { tinyTitleClient } from "../title/tiny-title-client";
|
||||
import { formatTitleUserMessage, normalizeGeneratedTitle } from "../title/title-text";
|
||||
|
||||
const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
|
||||
|
||||
const DEFAULT_TERMINAL_TITLE = "π";
|
||||
const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
||||
|
||||
const MAX_INPUT_CHARS = 2000;
|
||||
export const TITLE_LOCAL_FALLBACK_DELAY_MS = 10_000;
|
||||
const TITLE_MAX_TOKENS = 30;
|
||||
const REASONING_SAFE_MAX_TOKENS = 1024;
|
||||
const SET_TITLE_TOOL_NAME = "set_title";
|
||||
@@ -48,6 +51,78 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function raceFirstNonNull<T>(
|
||||
primary: Promise<T | null>,
|
||||
startFallback: () => Promise<T | null>,
|
||||
delayMs: number = TITLE_LOCAL_FALLBACK_DELAY_MS,
|
||||
onPrimaryWinAfterFallback?: () => void,
|
||||
): Promise<T | null> {
|
||||
const { promise, resolve } = Promise.withResolvers<T | null>();
|
||||
let resolved = false;
|
||||
let primarySettled = false;
|
||||
let fallbackStarted = false;
|
||||
let fallbackSettled = false;
|
||||
|
||||
const resolveOnce = (value: T | null): void => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
const maybeResolveNull = (): void => {
|
||||
if (primarySettled && fallbackStarted && fallbackSettled) resolveOnce(null);
|
||||
};
|
||||
const startFallbackOnce = (): void => {
|
||||
if (fallbackStarted || resolved) return;
|
||||
fallbackStarted = true;
|
||||
let fallback: Promise<T | null>;
|
||||
try {
|
||||
fallback = startFallback();
|
||||
} catch {
|
||||
fallbackSettled = true;
|
||||
maybeResolveNull();
|
||||
return;
|
||||
}
|
||||
void fallback.then(
|
||||
value => {
|
||||
fallbackSettled = true;
|
||||
if (value !== null) resolveOnce(value);
|
||||
else maybeResolveNull();
|
||||
},
|
||||
() => {
|
||||
fallbackSettled = true;
|
||||
maybeResolveNull();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const timer = setTimeout(startFallbackOnce, delayMs);
|
||||
void primary.then(
|
||||
value => {
|
||||
primarySettled = true;
|
||||
clearTimeout(timer);
|
||||
if (value !== null) {
|
||||
if (fallbackStarted) onPrimaryWinAfterFallback?.();
|
||||
resolveOnce(value);
|
||||
return;
|
||||
}
|
||||
startFallbackOnce();
|
||||
maybeResolveNull();
|
||||
},
|
||||
() => {
|
||||
primarySettled = true;
|
||||
clearTimeout(timer);
|
||||
startFallbackOnce();
|
||||
maybeResolveNull();
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a title for a session based on the first user message.
|
||||
*
|
||||
@@ -68,6 +143,41 @@ export async function generateSessionTitle(
|
||||
sessionId?: string,
|
||||
currentModel?: Model<Api>,
|
||||
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
|
||||
): Promise<string | null> {
|
||||
const tinyModel = settings.get("providers.tinyModel");
|
||||
if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
|
||||
return generateTitleOnline(firstMessage, registry, settings, sessionId, currentModel, metadataResolver);
|
||||
}
|
||||
|
||||
const onlineAbortController = new AbortController();
|
||||
const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
|
||||
title => title || null,
|
||||
() => null,
|
||||
);
|
||||
const startOnline = (): Promise<string | null> =>
|
||||
generateTitleOnline(
|
||||
firstMessage,
|
||||
registry,
|
||||
settings,
|
||||
sessionId,
|
||||
currentModel,
|
||||
metadataResolver,
|
||||
onlineAbortController.signal,
|
||||
);
|
||||
|
||||
return raceFirstNonNull(localTitle, startOnline, TITLE_LOCAL_FALLBACK_DELAY_MS, () => {
|
||||
onlineAbortController.abort();
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateTitleOnline(
|
||||
firstMessage: string,
|
||||
registry: ModelRegistry,
|
||||
settings: Settings,
|
||||
sessionId?: string,
|
||||
currentModel?: Model<Api>,
|
||||
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
const model = getTitleModel(registry, settings, currentModel);
|
||||
if (!model) {
|
||||
@@ -75,12 +185,7 @@ export async function generateSessionTitle(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Truncate message if too long
|
||||
const truncatedMessage =
|
||||
firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}…` : firstMessage;
|
||||
const userMessage = `<user-message>
|
||||
${truncatedMessage}
|
||||
</user-message>`;
|
||||
const userMessage = formatTitleUserMessage(firstMessage);
|
||||
|
||||
const apiKey = await registry.getApiKey(model, sessionId);
|
||||
if (!apiKey) {
|
||||
@@ -122,6 +227,7 @@ ${truncatedMessage}
|
||||
disableReasoning: true,
|
||||
toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
|
||||
metadata,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -134,7 +240,7 @@ ${truncatedMessage}
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = extractGeneratedTitle(response.content);
|
||||
const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content));
|
||||
|
||||
logger.debug("title-generator: response", {
|
||||
model: request.model,
|
||||
@@ -143,11 +249,7 @@ ${truncatedMessage}
|
||||
stopReason: response.stopReason,
|
||||
});
|
||||
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
|
||||
return title;
|
||||
} catch (err) {
|
||||
logger.debug("title-generator: error", {
|
||||
model: request.model,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models.
|
||||
|
||||
@@ -392,6 +392,11 @@ export function getModelDbPath(agentDir?: string): string {
|
||||
return dirs.agentSubdir(agentDir, "models.db", "data");
|
||||
}
|
||||
|
||||
/** Get the tiny title model cache directory (~/.omp/agent/cache/tiny-models). */
|
||||
export function getTinyModelsCacheDir(agentDir?: string): string {
|
||||
return dirs.agentSubdir(agentDir, path.join("cache", "tiny-models"), "cache");
|
||||
}
|
||||
|
||||
/** Get the sessions directory (~/.omp/agent/sessions). */
|
||||
export function getSessionsDir(agentDir?: string): string {
|
||||
return dirs.agentSubdir(agentDir, "sessions", "data");
|
||||
|
||||
Reference in New Issue
Block a user