Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
9906becb6This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Device turn-recap summarizer — self-contained port of the brain's `summarizeTurnText`.
*
* Produces the device's `recap\n\ntext` shape for one turn. Only the RECAP — a one-line headline,
* ≤15 words — comes from a model (a disposable one-shot of the same CLI engine as the interactive
* session). The `text` under it is the answer itself, flattened and clipped locally
* (`deriveTurnBody`), the same excerpt `SUMMARY_MODE=local` shows: nothing on the dial reads a
* model-written body any more (the tile shows the headline, the reader is off), the device protocol
* defines `text` as an excerpt, and a paraphrase nobody sees cost output tokens on every turn.
* Honors an AbortSignal so a newer turn can supersede a stale recap.
*/
import { existsSync, mkdirSync } from 'fs'
import { unlink } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { env } from '../config/env.js'
import type { AgentEngine } from '../engines/types.js'
import type { GatewayRuntime } from './gatewayRuntime.js'
import { openRouterComplete, resolveOpenRouterKey } from './openrouter.js'
import {
cleanupCursorOneShotSession,
configureOneShotPool,
runClaudeOneShot,
runCodexOneShot,
runCursorOneShot,
runOpencodeOneShot,
runKiloOneShot,
runPiOneShot,
runHermesOneShot,
runCommandCodeOneShot,
runDevinOneShot,
runMuseOneShot,
runAmpOneShot,
runGrokOneShot,
runAgyOneShot,
runCopilotOneShot,
setOneShotPoolActiveCounts,
setOneShotPoolDeviceConnected,
shutdownOneShotPool,
type OneShotOptions,
} from './oneshot.js'
const RECAP_MAX_WORDS = 15 // ~12-15 words; ONE complete sentence (20 overflowed the device tile).
const MAX_INPUT_CHARS = 50_000
// The previous turn's `recap\n\ntext` is a ≤15-word headline over a ≤250-char excerpt by construction;
// this is a guard against a stored summary from an older build, not a budget anyone is expected to hit.
const PREVIOUS_RECAP_MAX_CHARS = 1_000
const SUMMARY_SCRATCH = join(env.ADAPTER_DATA_DIR, 'summary-scratch')
function ensureSummaryScratch(): string {
if (!existsSync(SUMMARY_SCRATCH)) mkdirSync(SUMMARY_SCRATCH, { recursive: true, mode: 0o700 })
return SUMMARY_SCRATCH
}
configureOneShotPool({
cwd: ensureSummaryScratch(),
claudeModel: env.SUMMARY_MODEL,
codexModel: env.CODEX_SUMMARY_MODEL,
cursorModel: env.CURSOR_SUMMARY_MODEL,
opencodeModel: env.OPENCODE_SUMMARY_MODEL,
kiloModel: env.KILO_SUMMARY_MODEL,
piModel: env.PI_SUMMARY_MODEL,
commandcodeModel: env.COMMANDCODE_SUMMARY_MODEL,
effort: env.SUMMARY_EFFORT,
})
export function syncSummaryPoolSessions(sessions: Array<{ engine: AgentEngine }>): void {
const counts = { claude: 0, codex: 0, cursor: 0, opencode: 0, kilo: 0, pi: 0, commandcode: 0 }
for (const session of sessions) {
// Hermes, Devin, Muse and Amp take their recap prompt as argv (`muse exec <prompt>`, `amp -x <prompt>`),
// so they cannot be pre-warmed the way a stdin-fed CLI can — no pooled worker for them.
if (session.engine === 'hermes' || session.engine === 'devin' || session.engine === 'muse'
|| session.engine === 'amp' || session.engine === 'grok' || session.engine === 'agy' || session.engine === 'copilot'
|| session.engine === 'terminal') continue
counts[session.engine]++
}
setOneShotPoolActiveCounts(counts)
}
export function setSummaryPoolDeviceConnected(connected: boolean): void {
setOneShotPoolDeviceConnected(connected)
}
export function shutdownSummaryPool(): void {
shutdownOneShotPool()
}
/**
* A coarse WRITING-SYSTEM fingerprint — deliberately not a language identifier. Naming languages meant a
* hand-maintained word list per language and a drift check that only ever ran in one direction (it caught
* "should be English, came back Vietnamese" but never the reverse). Comparing which scripts a text uses,
* and how accented its Latin part is, catches "answered in a different language" for any pair of languages
* with nothing to maintain.
*/
interface ScriptProfile {
/** Share of letters per writing system, e.g. { Latin: 0.9, Han: 0.1 }. */
shares: Record<string, number>
/** Share of Latin letters carrying a diacritic — what separates Vietnamese/Polish/Turkish from English. */
accented: number
letters: number
}
const SCRIPTS: Array<[string, RegExp]> = [
['Han', /\p{Script=Han}/u],
['Hiragana', /\p{Script=Hiragana}/u],
['Katakana', /\p{Script=Katakana}/u],
['Hangul', /\p{Script=Hangul}/u],
['Cyrillic', /\p{Script=Cyrillic}/u],
['Arabic', /\p{Script=Arabic}/u],
['Hebrew', /\p{Script=Hebrew}/u],
['Thai', /\p{Script=Thai}/u],
['Devanagari', /\p{Script=Devanagari}/u],
['Greek', /\p{Script=Greek}/u],
['Latin', /\p{Script=Latin}/u],
]
function scriptProfile(text: string): ScriptProfile {
const counts: Record<string, number> = {}
let letters = 0
let accented = 0
for (const ch of text || '') {
if (!/\p{L}/u.test(ch)) continue
const hit = SCRIPTS.find(([, re]) => re.test(ch))
if (!hit) continue
letters++
counts[hit[0]] = (counts[hit[0]] || 0) + 1
// NFD splits an accented letter into base + combining mark(s); a bare ASCII letter never does.
if (hit[0] === 'Latin' && /\p{M}/u.test(ch.normalize('NFD'))) accented++
}
const shares: Record<string, number> = {}
if (letters) for (const [k, v] of Object.entries(counts)) shares[k] = v / letters
const latin = counts.Latin || 0
return { shares, accented: latin ? accented / latin : 0, letters }
}
/**
* True when `out` reads as a DIFFERENT language than `src`: it dropped (or invented) a writing system the
* other side relies on, or its Latin text lost (or gained) diacritics wholesale. Thresholds are loose on
* purpose — quoting a product name or a code identifier in another script must not trip it.
*/
function languageDrifted(src: string, out: string): boolean {
const a = scriptProfile(src)
const b = scriptProfile(out)
if (a.letters < 12 || b.letters < 12) return false // too little signal to judge
for (const [name] of SCRIPTS) {
if (name === 'Latin') continue
const sa = a.shares[name] || 0
const sb = b.shares[name] || 0
if (sa >= 0.15 && sb <= 0.02) return true // the source is written in it, the output is not
if (sb >= 0.15 && sa <= 0.02) return true // the output switched into a script the source never used
}
const latinBoth = (a.shares.Latin || 0) >= 0.5 && (b.shares.Latin || 0) >= 0.5
if (latinBoth && a.accented >= 0.08 && b.accented <= 0.02) return true
if (latinBoth && b.accented >= 0.08 && a.accented <= 0.02) return true
return false
}
// Trailing connectors (VN + EN) a recap must never END on — cutting here would leave it dangling
// ("…2-1, nhờ"). Stripped from the tail if a hard cut lands on one. Prefix with a separator rather
// than \b: JS \b is ASCII-only, so it fails on a Vietnamese word ending in a diacritic (e.g. "nhờ").
const DANGLING_TAIL =
/[\s,;:–—-]+(nhờ|vì|do|bởi|để|và|hoặc|nhưng|mà|với|của|cho|khi|nếu|thì|theo|cùng|rằng|là|nên|and|or|but|with|because|since|so|to|for|of|in|on|at|the|a|an)[\s,;:–—-]*$/i
/**
* Cap the RECAP to a clean newspaper-style headline: ONE line, ≤ max words, and NEVER any "…". The model is
* told to write a complete headline within budget; this is the hard guard. If it overshoots we cut at the
* last clause boundary and strip any dangling connector so it still ends on a complete point — but we do
* NOT append an ellipsis (a headline reads as finished, not truncated).
*/
function capRecap(text: string, max: number): string {
// Drop any ellipsis the model emitted (leading/trailing/mid) — headlines never carry "…".
let t = text.replace(/…|\.{2,}/g, ' ').replace(/\s+/g, ' ').trim()
const words = t.split(/\s+/)
if (words.length > max) {
let head = words.slice(0, max).join(' ')
const b = Math.max(head.lastIndexOf(','), head.lastIndexOf(';'), head.lastIndexOf('. '))
if (b > head.length * 0.5) head = head.slice(0, b)
t = head
}
// Strip a dangling connector / trailing punctuation so the headline ends on a complete point. No "…".
let prev: string
do { prev = t; t = t.replace(DANGLING_TAIL, '').replace(/[\s,;:–—-]+$/, '').trim() } while (t !== prev)
return t
}
// The CLI mangles a cwd into its projects-dir name (every non-alphanumeric → '-'); used to delete
// the throwaway one-shot transcript so it never lingers under the watched projects root.
function scratchTranscript(scratch: string, id: string): string {
const mangled = scratch.replace(/[^a-zA-Z0-9]/g, '-')
return join(homedir(), '.claude', 'projects', mangled, `${id}.jsonl`)
}
async function cleanupRecapSession(engine: AgentEngine, scratch: string, sessionId: string | null): Promise<void> {
if (!sessionId) return
if (engine === 'claude') {
await unlink(scratchTranscript(scratch, sessionId)).catch(() => {})
return
}
if (engine === 'cursor') await cleanupCursorOneShotSession(sessionId)
}
/** The tile headline. */
export const RECAP_MAX_CHARS = 60
/** The body the dial's tap-to-read screen shows. */
export const BODY_MAX_CHARS = 250
/**
* Turn the engine's own final message into the dial's `recap\n\nbody`, with no model in the loop.
*
* The dial is cabled to a Mac whose window is already showing this text in full, so the recap is a
* GLANCE, not a substitute for reading: whoever wants the detail turns their head. That is what buys
* the ~9s the one-shot used to cost on every turn, and it is why this truncates with an ellipsis where
* the model-written recap never did — "…" is honest here precisely because the rest is one glance away.
*
* The work is not the cut, it is what gets cut. An engine's last message is markdown: a heading, then
* bullets, then maybe a table. `slice(0, 60)` of that yields "## Kết quả\n\n- **SJC**: 149,1 triệu
* đồng/lượn" — a markdown heading and a severed word. So the text is flattened to prose first, and the
* recap comes from the first line that carries any.
*/
export function deriveTurnSummary(text: string): string | null {
// Markers go, LINE BREAKS STAY. Flattening first merges a label into the sentence under it — "Kết
// quả:" and its answer become one run with no boundary left to find, and the recap opens on the
// word that says least.
const stripped = stripMarkdown(text)
const body = stripped.replace(/\s+/g, ' ').trim()
if (!body) return null
// The answer's OPENING is the headline. The engine-specific turn readers hand this the answer alone —
// Codex's `commentary` messages ("I'll check the page", "I'm about to ask") are dropped there, since
// a headline taken from those announced the work instead of stating the result.
const recap = clip(firstProseLine(stripped) || body, RECAP_MAX_CHARS)
return `${recap}\n\n${deriveTurnBody(text)}`
}
/** The `text` under a recap: the answer flattened to one line and clipped — a glance, never a
* paraphrase. Shared by both recap writers so the body reads the same whoever wrote the headline. */
export function deriveTurnBody(text: string): string {
return clip(stripMarkdown(text).replace(/\s+/g, ' ').trim(), BODY_MAX_CHARS)
}
/** The headline in a one-shot's output: its first non-empty line. A model that still writes a second
* paragraph is tolerated, not concatenated — and not judged for language drift either. */
function headlineOf(output: string): string {
return output.split('\n').map((line) => line.trim()).find(Boolean) ?? ''
}
/** Cut to `max` characters on a word boundary, marking the cut. Never mid-word if it can be helped. */
function clip(text: string, max: number): string {
const t = text.trim()
if (t.length <= max) return t
const head = t.slice(0, max - 1)
const space = head.lastIndexOf(' ')
// Only honour the word boundary when it is not throwing most of the budget away — a long unbroken
// token (a path, a URL) would otherwise collapse the line to nothing.
return `${(space > max * 0.6 ? head.slice(0, space) : head).replace(/[\s,;:–—-]+$/, '')}…`
}
/**
* Markdown → one flat run of prose the dial can draw.
*
* Fenced code and tables go entirely: a tile is 466px of round glass, and half a shell command wrapped
* across three lines says less than the sentence next to it.
*/
function stripMarkdown(text: string): string {
return text
.replace(/```[\s\S]*?```/g, '\n') // fenced code
.replace(/^\s*\|.*\|\s*$/gm, '') // table rows
.replace(/^\s{0,3}#{1,6}\s*/gm, '') // headings — the marker, not the words
.replace(/^\s*[-*+]\s+/gm, '') // bullet markers
.replace(/^\s*\d+\.\s+/gm, '') // ordered-list markers
.replace(/^\s*>\s?/gm, '') // block quotes
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // links and images → their label
.replace(/[*_~`]/g, '') // emphasis and inline code marks
.replace(/[ \t]+/g, ' ')
.trim()
}
/**
* The first line with real words in it.
*
* Skips lead-ins that name the shape of the answer rather than the answer — "Kết quả:", "Here's what I
* found:" — because those are exactly what a heading collapses into once its `##` is gone, and a tile
* reading "Kết quả" has told the user nothing.
*/
function firstProseLine(stripped: string): string {
// Lines first, then sentences within a line: the line is the structure markdown actually carries,
// and a label only reads as a label while it still has a line of its own.
const candidates: string[] = []
for (const line of stripped.split(/\n+/)) {
for (const piece of line.split(/(?<=[.!?])\s+/)) {
const candidate = piece.trim()
if (candidate.length < 3) continue
if (/^[^\p{L}\p{N}]+$/u.test(candidate)) continue // punctuation or symbols only
candidates.push(candidate)
}
}
// A LABEL is a short line that never finishes a thought — "Kết quả:", and the same words again once
// a heading's `##` has been taken off it. Both name the shape of the answer instead of giving it, so
// skip past them while there is anything else to say. Alone, a short line IS the answer.
const answer = candidates.find((c) => c.length > LABEL_MAX_CHARS || /[.!?]$/.test(c)) ?? candidates[0]
return (answer ?? '').replace(/:$/, '')
}
/** Longer than this and a line is saying something, not naming a section. */
const LABEL_MAX_CHARS = 24
/**
* `recap = llm(instruct, previousRecap, userMessage, text)`.
*
* `previousRecap` is the stored summary of this agent's LAST turn. It is context, not content: it lets
* a turn whose answer is "Done — same change in the other two files" recap as what was done, instead of
* a fragment that names nothing. The prompt fences it off so it can only resolve references, never be
* reported as this turn's news. Absent on a session's first turn.
*/
export async function summarizeTurnText(
text: string,
signal?: AbortSignal,
userMessage?: string,
engine: AgentEngine = 'claude',
gateway?: GatewayRuntime,
previousRecap?: string,
): Promise<string | null> {
let last = (text || '').trim()
if (!last) return null
if (last.length > MAX_INPUT_CHARS) last = last.slice(-MAX_INPUT_CHARS)
// Continuity only. Flattened — the stored body carries line breaks the model would otherwise
// read as structure to preserve — and capped so an odd stored value cannot crowd out the turn.
const prev = (previousRecap || '').replace(/\s+/g, ' ').trim().slice(0, PREVIOUS_RECAP_MAX_CHARS)
const prevBlock = prev
? `For continuity ONLY, the recap of this assistant's PREVIOUS turn was: «${prev}». Use it to ` +
`resolve references in this turn ("it", "that one", "the same fix", "as before", "the other file") ` +
`and to keep names and terms consistent — nothing more. Your recap describes THIS turn's message ` +
`alone: do NOT repeat, restate or merge in the previous recap, and do NOT report anything from it ` +
`as if it happened now. If this turn's message is complete on its own, ignore the previous recap ` +
`entirely.\n\n`
: ''
// If we know the user's request for this turn, tell the recap to LEAD with the direct answer to it
// (asked a price → give the price), not a generic characterization of the topic — the answer text
// still comes from the assistant's message. Whitespace-flattened + capped so a long paste can't
// dominate the prompt.
const ask = (userMessage || '').replace(/\s+/g, ' ').trim().slice(0, 400)
const askBlock = ask
? `The user's request this turn was: «${ask}». Use this request as the turn's language signal ` +
`and context, but take all facts from the assistant message below. Your recap MUST lead with the DIRECT answer to that ` +
`exact request — the specific fact, number, decision or result they asked for (asked a price → the ` +
`price; asked yes/no → the verdict; asked "how" → the key step), taken from the assistant's message ` +
`below, then a few words of context. Do NOT lead with a generic characterization of the topic, and ` +
`do NOT invent anything not in the message.\n\n`
: ''
// Kept in sync with the hosted runtime’s recap. Guards two failure
// modes: (1) META-TASK LEAK — the summarizer writing its own intent ("I need to convert…") into the
// recap instead of re-voicing the content; (2) WRONG LANGUAGE — the recap drifting away from the
// source message's language.
const prompt =
`LANGUAGE RULE (most important): choose the output language ONLY from the user's request for this ` +
`turn and the assistant message between the --- markers below. If both are in English, output English. ` +
`If they use another language, output that language. If they mix languages, preserve that mix naturally. ` +
`Never switch to a language that does not appear in the user's request or the assistant message. Ignore ` +
`previous conversation (including the previous recap, if one is quoted below), account locale, ` +
`environment locale, and the language of these instructions.\n\n` +
prevBlock +
askBlock +
`Between the --- markers below is a message the assistant already sent to the user. Re-voice its ` +
`CONTENT back to the user, in the FIRST PERSON as that same assistant (its own "I"). You are ONLY ` +
`restating what the message says — you are NOT performing any task and NOT describing this ` +
`summarizing job. NEVER write a meta or intent sentence such as "I need to…", "I will ` +
`summarize/convert/translate…", "let me…", or "the message is about…". Output ONLY the one line ` +
`described below — nothing before it, nothing after it, no headings, labels or preamble:\n` +
`A NEWSPAPER HEADLINE for this turn: ONE punchy, self-contained line of at most ` +
`${RECAP_MAX_WORDS} words. State the SINGLE most important thing the user needs from this turn — the ` +
`direct answer, decision, result or recommendation to their request (what was said or done, NOT a plan ` +
`of what you will do next, NOT a description of the topic) — and FRONT-LOAD it so the first few words ` +
`alone carry the point (only the opening may be shown). Headline voice: active, specific, punchy; NO ` +
`hedging or throat-clearing (never open with "I think…", "This is a close one…", "It is about…", ` +
`"Regarding…") — conclusion first, then at most a few words of why; if the source hedges, still commit ` +
`to its leaning up front. It MUST be COMPLETE within ${RECAP_MAX_WORDS} words: never cut off mid-idea, ` +
`never end on a connector/preposition/unfinished clause, and NEVER use "…", "..." or any ellipsis or ` +
`trailing dots. If you would run long, TIGHTEN the wording into a shorter headline — do not truncate. ` +
`Do NOT enumerate long lists — give the gist (use counts like "4 forwards" instead of naming everyone). ` +
`PLAIN TEXT ONLY: no emoji, markdown, tables, bullets or URLs. Restate the substance itself; do NOT ` +
`narrate the process (avoid "I wrote/did…").\n\n---\n${last}\n\n---\n` +
`IMPORTANT: before you answer, re-check the user's request and source message. Write the headline ` +
`in their language and register. Do NOT translate or switch to any language absent from those two ` +
`inputs. Keep technical terms, code, and product names as-is.`
const scratch = ensureSummaryScratch()
const t0 = Date.now()
// A gateway agent (`ori claude`, `ori codex`) bills an OpenRouter account, and its user may hold no
// vendor credential at all — the engine one-shot would spawn a CLI that cannot authenticate and the
// recap would silently never arrive. Same prompt, same output contract, one HTTPS call instead.
const gatewayKey = gateway?.kind === 'ori' && env.ORI_SUMMARY_MODEL
? await resolveOpenRouterKey(gateway)
: null
const model = gatewayKey
? env.ORI_SUMMARY_MODEL
: engine === 'claude'
? env.SUMMARY_MODEL
: engine === 'codex'
? env.CODEX_SUMMARY_MODEL
: engine === 'cursor'
? env.CURSOR_SUMMARY_MODEL
: engine === 'pi'
? env.PI_SUMMARY_MODEL
: engine === 'hermes'
? env.HERMES_SUMMARY_MODEL
: engine === 'commandcode'
? env.COMMANDCODE_SUMMARY_MODEL
: engine === 'devin'
? env.DEVIN_SUMMARY_MODEL
: engine === 'muse'
? env.MUSE_SUMMARY_MODEL
// Amp's is an agent MODE (low|medium|high|ultra), not a model name — it exposes no models.
: engine === 'amp'
? env.AMP_SUMMARY_MODE
: engine === 'kilo'
? env.KILO_SUMMARY_MODEL
: engine === 'grok'
? env.GROK_SUMMARY_MODEL
: engine === 'agy'
? env.AGY_SUMMARY_MODEL
: engine === 'copilot'
? env.COPILOT_SUMMARY_MODEL
: env.OPENCODE_SUMMARY_MODEL
const effort = engine === 'cursor' ? 'model-defined' : env.SUMMARY_EFFORT
console.log(
gatewayKey
? `[recap] openrouter ${engine} · model=${model} · inputChars=${last.length}${ask ? ' · withAsk' : ''}${prev ? ' · withPrev' : ''}`
: `[recap] one-shot ${engine} · model=${model} · effort=${effort} · inputChars=${last.length}${ask ? ' · withAsk' : ''}${prev ? ' · withPrev' : ''}`,
)
const runEngine = engine === 'claude'
? runClaudeOneShot
: engine === 'codex'
? runCodexOneShot
: engine === 'cursor'
? runCursorOneShot
: engine === 'pi'
? runPiOneShot
: engine === 'hermes'
? runHermesOneShot
: engine === 'commandcode'
? runCommandCodeOneShot
: engine === 'devin'
? runDevinOneShot
: engine === 'muse'
? runMuseOneShot
: engine === 'amp'
? runAmpOneShot
: engine === 'kilo'
? runKiloOneShot
: engine === 'grok'
? runGrokOneShot
: engine === 'agy'
? runAgyOneShot
: engine === 'copilot'
? runCopilotOneShot
: runOpencodeOneShot
// One shape for both paths so the language-drift retry and the headline cap below stay single-source.
const run: (options: OneShotOptions) => Promise<{ text: string; sessionId: string | null }> = gatewayKey
? async (options) => ({
text: await openRouterComplete({
prompt: options.prompt, model, apiKey: gatewayKey, signal: options.signal, maxTokens: 160,
}) ?? '',
sessionId: null,
})
: runEngine
let r = await run({ prompt, model, effort: env.SUMMARY_EFFORT, cwd: scratch, signal })
console.log(`[recap] ${gatewayKey ? 'openrouter' : 'one-shot'} returned in ${Date.now() - t0}ms · rawLen=${(r.text || '').length}`)
await cleanupRecapSession(engine, scratch, r.sessionId)
// Language check without naming a language: compare the writing system of the output against the inputs
// it was supposed to mirror. Works in both directions and for any language pair.
if (languageDrifted(`${ask} ${last}`, headlineOf(r.text || ''))) {
console.warn('[recap] one-shot language drift detected · retrying')
r = await run({
prompt: prompt + `\n\nRETRY: your previous output was NOT in the same language as the user's request ` +
`and the assistant message. Rewrite it in exactly that language — same script and same diacritics — ` +
`as one line.`,
model,
effort: env.SUMMARY_EFFORT,
cwd: scratch,
signal,
})
await cleanupRecapSession(engine, scratch, r.sessionId)
}
const recap = capRecap(headlineOf(r.text || ''), RECAP_MAX_WORDS)
if (!recap) { console.warn('[recap] one-shot produced no usable recap (empty after cap)'); return null }
// The body is the answer's own excerpt, never the model's — and of the whole answer (`text`), not
// the tail the prompt was fed (`last`), so a very long answer excerpts from its start like local mode.
const body = deriveTurnBody(text)
return body ? `${recap}\n\n${body}` : recap
}