Loading files
/**
* 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
}
50b3be768This file is pinned to the selected release. Download the original to open it in your design tool.