Skip to content

PromptProcessing: a first turn made only of a local slash command + attachment names the session after the command's own stdout ("Set … Default Model"), permanently #2033

Description

@xmasyx

Summary

A session whose first turn is a local slash command plus an attachment (e.g. /model, then a screenshot with no typed text) is named after the command's own output, permanently. The naming path never sees a user word, so it names the session from harness-authored text.

Observed name: "Set Fable Five Default Model" — for a session whose actual work was unrelated (memory-overflow triage and a hook fix). The name is first-prompt-only and permanent, so every later tab title derives from it too (followupTitleFromSessionName).

This is the same surface as #1718 / #1845 / #1906. The [image|screenshot|pasted] strip added for #1845 is present and works; it just does not cover the other class of harness-authored text, the local-command records.

Root cause — two independent holes, both in LifeOS/install/hooks/PromptProcessing.hook.ts

Claude Code writes a local slash command into the transcript as three synthetic type:"user" records:

<local-command-caveat>Caveat: The messages below were generated by the user while running local commands…</local-command-caveat>
<command-name>/model</command-name>
            <command-message>model</command-message>
            <command-args></command-args>
<local-command-stdout>Set model to `X` and saved as your default for new sessions</local-command-stdout>

Hole 1 — the hook's own prompt. With an attachment and no typed text, the hook receives "[Image #1]". extractSlashCommandName (L~845) does not match, because <command-name> is in the transcript, not in this prompt. isFirstPrompt is true, so naming proceeds on a prompt that contains no user words at all.

Hole 2 — the transcript context handed to inference. getRecentContext (L808) keeps every type:"user" record verbatim:

if (text.trim()) turns.push({ role: 'User', text: text.slice(0, 200) });

All three synthetic records above are type:"user", so the model is handed Set model to X and saved as your default as if the user had typed it, and dutifully names the session after it (L1021 passes this context into the inference call). sanitizePromptForNaming is applied to the prompt, never to the context, so its <system-reminder> / <task-notification> strips do not help here.

Note the two holes are separable: hole 2 alone mis-names any session whose first typed message follows a /command in the same turn.

Reproduction

  1. Start a session. First turn: run a local slash command (/model, /config, …) and attach an image with no typed text.
  2. Second turn: type the real request.
  3. MEMORY/STATE/session-names.json holds a name derived from the command's stdout, and the tab title follows it for the rest of the session.

Suggested fix

One helper plus two call sites; both changes are in PromptProcessing.hook.ts.

/** Strip harness-authored markup so only user-typed words remain. */
export function stripHarnessMarkup(text: string): string {
  return text
    .replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi, ' ')
    .replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/gi, ' ')
    .replace(/<command-(?:name|message|args)>[\s\S]*?<\/command-(?:name|message|args)>/gi, ' ')
    .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
    .replace(/<task-notification>[\s\S]*?<\/task-notification>/gi, ' ')
    .replace(/\[(?:image|screenshot|pasted)[^\]]*\]/gi, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

/** True if, harness markup removed, the prompt still carries a user word. */
export function hasUserWords(prompt: string): boolean {
  return /\p{L}{2,}/u.test(stripHarnessMarkup(prompt));
}

Hole 2 — filter the context (getRecentContext, L808):

const own = stripHarnessMarkup(text);
if (own) turns.push({ role: 'User', text: own.slice(0, 200) });

Hole 1 — do not name a first prompt with no user words (right after sanitizedPrompt is computed, L895). Because naming is gated on !existingNames[sessionId], exiting without storing leaves the next prompt as the first one, so the session is named from the first message that actually says something:

if (isFirstPrompt && !hasUserWords(prompt)) {
  console.error('[PromptProcessing] First prompt carries no user words (local command / attachment only) — naming deferred to the next prompt');
  process.exit(0);
}

\p{L} rather than [a-z] keeps this working for non-Latin scripts, which matters alongside #1906.

Verification performed locally

  • Unit, both poles: the three real /model records and "[Image #1]" each strip to '' and fail hasUserWords; user words survive next to an attachment or a command's stdout ("[Image #1] the title is wrong""the title is wrong"), and a two-letter reply still counts as a user word.
  • Negative pole on the live hook: the pre-fix hook, fed a synthetic transcript with the three records and prompt "[Image #1]", names the session "Set Fable Five Default Model" — the defect reproduced exactly. The patched hook logs naming deferred and writes no entry.

Verified against LifeOS/install/hooks/PromptProcessing.hook.ts at main (v7.40.4).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions