diff --git a/CONTEXT.md b/CONTEXT.md index f6c70cd..bb98ad3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,7 +70,10 @@ A process a Session spawns — a subagent, a review crew. It appears in the rost _Avoid_: child Session, subagent (the agent's mechanism word), crew **Session Name**: -What the user and the system call one Session — the agent's own name for it. +What the user and the system call one Session: ` · `, where the project is the +Git repository the Session is working in — its own directory when it is in none — and the task +is the agent's own name for the Session. Composed by the lane that first saw the Session and +never changed after that, because the user says it back to address the Session. _Avoid_: label, title **Relay**: diff --git a/src/gpt_voicecoding/adapters/agent/_naming.py b/src/gpt_voicecoding/adapters/agent/_naming.py new file mode 100644 index 0000000..ded0de9 --- /dev/null +++ b/src/gpt_voicecoding/adapters/agent/_naming.py @@ -0,0 +1,66 @@ +"""How both lanes turn a project and a task into one Session Name. + +**Pure, and shared.** Two lanes compose the same shape from different facts — +Claude's official roster `name`, Codex's daemon `Thread.name` — and a rule that +lived twice would drift twice. Nothing here touches the filesystem, a process or +a clock: the caller resolves the project half (`_project.py`) and hands it in, +which is what keeps this testable against strings rather than against whichever +repositories happen to be on the machine. + +**The task half is validated the way the reference implementation validated it** +(`legacy@1d32845:bridge/labels.py:97-106`, *adapted*): non-empty after stripping, +and one line. Legacy *raised* `SessionLabelError` on both — and could, because +its caller was a single self-report ingress handling one Session that had just +spoken. This caller is a discovery pass over every row on the machine every few +seconds, where one bad string must never cost an enumeration: an unnameable row +is one row without a name, not a lane that failed. So the answer is `None` — +"this Session has no name yet", the state the roster already renders +(`core/sessions.py`) — and the reason is logged, so a name that never appears is +findable rather than silent. (#78 writes the signature as returning a name; +amended by advisor ruling, 2026-08-27.) + +**What is deliberately not here.** No transcript-derived task, no `ai-title`, +no mutable history: legacy measured that route at 30% of Sessions never writing +one and the rest writing one a median of 440 s late +(`legacy@1d32845:bridge/labels.py:73-84`, *dropped, because* a record a product +writes when it feels like it cannot carry a name the user speaks with). A name +composed here is composed from a fact the lane already had. +""" + +from __future__ import annotations + +import logging + +from gpt_voicecoding.seams.identity import NAME_SEPARATOR, SessionName + +_log = logging.getLogger(__name__) + + +def compose(project_name: str, task: str) -> SessionName | None: + """`` · ``, or `None` when the parts cannot make one. + + Both halves are stripped first, because the sources are other people's + strings: a roster field and a daemon field, either of which may arrive + padded. A task that is empty or spans more than one line is refused rather + than flattened — a Session Name is spoken and typed after `@`, so a name + carrying a newline would be a name the user cannot say back. + + The separator is refused inside either half by `SessionName` itself, and + that refusal is made here for the same reason as the rest: a Codex thread + the user named `a · b` is a Session that has no name, not a lane that broke. + """ + project = project_name.strip() + wanted = task.strip() + if not project or not wanted: + return _unnamed(project_name, task, "a half with no words in it") + if len(wanted.splitlines()) > 1: + return _unnamed(project_name, task, "a task spanning more than one line") + if NAME_SEPARATOR.strip() in project or NAME_SEPARATOR.strip() in wanted: + return _unnamed(project_name, task, f"a half carrying {NAME_SEPARATOR.strip()!r}") + return SessionName(project=project, task=wanted) + + +def _unnamed(project_name: str, task: str, reason: str) -> None: + """Say why this Session has no name, and answer that it has none.""" + _log.info("no Session Name for %r · %r: %s", project_name, task, reason) + return None diff --git a/src/gpt_voicecoding/adapters/agent/_project.py b/src/gpt_voicecoding/adapters/agent/_project.py new file mode 100644 index 0000000..5d14862 --- /dev/null +++ b/src/gpt_voicecoding/adapters/agent/_project.py @@ -0,0 +1,136 @@ +"""What project a Session is working on, read from its workspace. + +The project half of every Session Name (`_naming.py`), and the **one place** +either lane runs a command to get it — a rule worth a module of its own, because +this is asked once per row per discovery tick and a second copy of it in the +other lane would be a second subprocess per row per tick. + +**Git first, the directory second.** `git rev-parse --git-common-dir` is what the +reference implementation asked (`legacy@1d32845:bridge/labels.py:28-70`), and it +is the right question: it answers with the *repository's* directory from any +worktree or subdirectory, so two Sessions in `repo/` and `repo/src/` are working +on one project and are named for it. Legacy then raised when the answer was not +a repository at all, and legacy's label never existed. That is **adapted** here: v1.0 +bridges every Session the user starts, so one in `~/scratch` is named +`scratch · ` from the directory's own basename rather than left unnamed +and unspeakable. The basename is a fact about the Session, not a guess. + +**Cached per resolved workspace, for the life of the adapter.** A workspace does +not change which repository it belongs to while a Session runs in it, and the +cadence would otherwise pay a subprocess per Session every five seconds. The +cache is keyed on the realpath, so the same repository reached by two paths is +one entry. **The basename fallback is remembered too**, deliberately: a `git` +that was busy the first time a workspace was seen would otherwise be asked again +on every tick for a Session whose name the registry has already frozen, and the +second answer could never reach the roster anyway. + +**A failure is a directory name, never an exception.** Discovery is answering +"what is running"; `git` being absent, slow or angry is not a reason to lose a +row, and the basename is standing by. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Final + +_log = logging.getLogger(__name__) + +#: The command, and the flags that make its answer one absolute path. Asked of +#: the Session's own workspace, so the answer is about the Session's repository +#: and not about wherever this engine happens to be running. +GIT_COMMAND: Final = ("git", "rev-parse", "--path-format=absolute", "--git-common-dir") + +#: How long `git` is given before the directory's basename is the answer. Short +#: on purpose: this runs inside a discovery tick, and a name is worth less than +#: the roster it would hold up. +COMMAND_TIMEOUT_SECONDS: Final = 5.0 + +#: What a Git common directory is called. A `rev-parse` answer that is not one is +#: an answer this build does not understand, and it falls back rather than +#: cutting a project name out of a shape it is guessing at. +GIT_DIRECTORY_NAME: Final = ".git" + + +#: How `git` is asked, and what it answered on stdout — `None` when it could not +#: be asked or refused. Injected for the reason the Claude lane injects its +#: roster command: a test that shelled out here would be measuring whichever +#: repositories the machine running it happens to have. +GitAnswer = Callable[[Path], Awaitable[str | None]] + + +class ProjectNames: + """The project name for each workspace this lane has seen, read once each.""" + + def __init__( + self, *, ask: GitAnswer | None = None, timeout_seconds: float = COMMAND_TIMEOUT_SECONDS + ) -> None: + self._timeout_seconds = timeout_seconds + self._ask = ask or self._run_git + #: realpath → the project name it resolved to. + self._known: dict[str, str] = {} + + async def of(self, workspace: Path) -> str | None: + """This workspace's project name, or `None` if there is no workspace. + + `None` means the row carried no `cwd` at all — the lanes spell that + `Path()` — and it is the only case that yields no name. Everything else + resolves: a repository to its own directory name, anything else to the + workspace's basename. + """ + if not str(workspace).strip() or str(workspace) == ".": + return None + resolved = os.path.realpath(workspace) + remembered = self._known.get(resolved) + if remembered is not None: + return remembered + + found = _project_in(await self._ask(Path(resolved)) or "") or Path(resolved).name.strip() + if not found: + return None + self._known[resolved] = found + return found + + async def _run_git(self, workspace: Path) -> str | None: + """Ask `git` about this workspace, and hand back what it said on stdout.""" + try: + process = await asyncio.create_subprocess_exec( + *GIT_COMMAND, + cwd=workspace, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except (OSError, ValueError) as unavailable: + _log.info("could not ask git about %s: %s", workspace, unavailable) + return None + try: + out, _ = await asyncio.wait_for(process.communicate(), self._timeout_seconds) + except TimeoutError: + process.kill() + await process.wait() + _log.info("git did not answer about %s within %ss", workspace, self._timeout_seconds) + return None + if process.returncode != 0: + return None + return out.decode("utf-8", errors="replace") + + +def _project_in(answer: str) -> str | None: + """The project name inside one `rev-parse` answer, or `None` if it is not one. + + Three shapes are refused, all of them legacy's (`labels.py:56-70`): more than + one line, a path that is not absolute, and a directory that is not `.git`. + Each would mean the answer is not the one this asked for, and a name cut out + of it would be this build guessing. + """ + text = answer.strip() + if not text or len(text.splitlines()) > 1: + return None + common = Path(text) + if not common.is_absolute() or common.name != GIT_DIRECTORY_NAME: + return None + return common.parent.name.strip() or None diff --git a/src/gpt_voicecoding/adapters/agent/claude/adapter.py b/src/gpt_voicecoding/adapters/agent/claude/adapter.py index b27869a..7d901f6 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/adapter.py +++ b/src/gpt_voicecoding/adapters/agent/claude/adapter.py @@ -59,6 +59,7 @@ from datetime import UTC, datetime from pathlib import Path +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.adapters.agent.claude import discovery as claude_discovery from gpt_voicecoding.adapters.agent.claude import inbox, stop_analysis, transcript_tail from gpt_voicecoding.adapters.agent.claude.approval import ( @@ -197,6 +198,10 @@ def __init__( #: The lane's one opener of a transcript file, shared by what a Session #: stopped on (#75) and how far along it is (#76). self._transcripts = TranscriptReader() + #: The project half of every Session Name this lane composes, read once + #: per workspace and kept for the life of the adapter (#78). Held here + #: rather than inside `discovery` so the cache outlives one tick. + self._projects = ProjectNames() self._windows = ReplyWindowWatcher( settings=self._settings, emit=self._emit, stopped_on=self.stopped_on ) @@ -425,7 +430,7 @@ async def discover(self) -> LaneDiscovery: five seconds, for the whole machine (`core/bridge.py:442`) — and `inspect` reads the same rows. """ - lane = await claude_discovery.discover() + lane = await claude_discovery.discover(projects=self._projects) if not lane.enumerated: return lane return replace(lane, rows=tuple(self._row_with_stop(row) for row in lane.rows)) diff --git a/src/gpt_voicecoding/adapters/agent/claude/discovery.py b/src/gpt_voicecoding/adapters/agent/claude/discovery.py index 483f0cd..aa9891f 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/discovery.py +++ b/src/gpt_voicecoding/adapters/agent/claude/discovery.py @@ -30,10 +30,12 @@ import json import logging from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Final +from gpt_voicecoding.adapters.agent import _naming +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.seams.agent import ( LaneDiscovery, SessionInspection, @@ -107,7 +109,9 @@ async def run_command(argv: list[str]) -> CommandResult: ) -async def discover(*, run: Runner = run_command) -> LaneDiscovery: +async def discover( + *, run: Runner = run_command, projects: ProjectNames | None = None +) -> LaneDiscovery: """Every Claude Session running under this config directory, or why none. **A lane that could not look says so; it never reports an empty machine.** @@ -140,10 +144,10 @@ async def discover(*, run: Runner = run_command) -> LaneDiscovery: ) ) - return LaneDiscovery(rows=tuple(_rows(document))) + return LaneDiscovery(rows=tuple(await _rows(document, projects or ProjectNames()))) -def _rows(document: list[Any]) -> list[SessionInspection]: +async def _rows(document: list[Any], projects: ProjectNames) -> list[SessionInspection]: """Every row that can be read, skipping the ones that cannot. One unreadable row is not a broken roster, and refusing the whole document @@ -158,10 +162,36 @@ def _rows(document: list[Any]) -> list[SessionInspection]: if inspection is None: _log.info("skipped a roster row this build cannot address: %r", row) continue - found.append(inspection) + found.append(await _named(inspection, row, projects)) return found +async def _named( + inspection: SessionInspection, row: dict[str, Any], projects: ProjectNames +) -> SessionInspection: + """The same row, carrying its Session Name. + + **The task half is the roster's own `name`** — `workspace-claude-ed` and the + like — which is the official answer to "what is this Session called" and is + on every row from the moment the Session exists (#73). Nothing is asked of + the Session to get it and no transcript is opened for it, which is what makes + it stable enough to be the name the user speaks + (`legacy@1d32845:bridge/hook.py:215-253` had the Session report a task title + over the hook instead — *dropped, because* the amended #67 port table removed + that route on 2026-08-25). + + The project half is the workspace's, resolved here because this is the lane + that knows the workspace. A row with neither half stays unnamed. + """ + task = _name(row) + if task is None: + return inspection + project = await projects.of(inspection.workspace) + if project is None: + return inspection + return replace(inspection, name=_naming.compose(project, task)) + + def _inspection(row: dict[str, Any]) -> SessionInspection | None: """One roster row as the seam holds it, or `None` if it is not addressable.""" session_id = row.get("sessionId") @@ -195,7 +225,6 @@ def _inspection(row: dict[str, Any]) -> SessionInspection | None: # began is not when it last did anything. progress=None, last_activity=None, - name=_name(row), ) @@ -215,6 +244,6 @@ def _waiting_for(state: SessionState) -> WaitingFor: def _name(row: dict[str, Any]) -> str | None: - """The agent's own name for this Session — `workspace-claude-ed` and the like.""" + """The task half of this Session's name, straight off the official roster.""" name = row.get("name") return name.strip() if isinstance(name, str) and name.strip() else None diff --git a/src/gpt_voicecoding/adapters/agent/codex/adapter.py b/src/gpt_voicecoding/adapters/agent/codex/adapter.py index 828e423..1cf6616 100644 --- a/src/gpt_voicecoding/adapters/agent/codex/adapter.py +++ b/src/gpt_voicecoding/adapters/agent/codex/adapter.py @@ -57,6 +57,7 @@ from typing import Any from gpt_voicecoding import __version__ +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.adapters.agent.codex import approvals as approval_wire from gpt_voicecoding.adapters.agent.codex import discovery as codex_discovery from gpt_voicecoding.adapters.agent.codex import thread_tail @@ -186,6 +187,9 @@ def __init__( ) #: What each loaded thread last said, read at most once per change. self._turns = codex_discovery.TurnCache() + #: The project half of every Session Name this lane composes, read once + #: per workspace and kept for the life of the adapter (#78). + self._projects = ProjectNames() #: How the machine's own process table is read. Injected for the reason #: the daemon is: a test that shelled out to `ps` would be a test whose #: answer depends on what the person running it happens to have open. @@ -348,6 +352,7 @@ async def discover(self) -> LaneDiscovery: processes=self._processes, turns=self._turns, daemon_note=self._daemon.note, + projects=self._projects, ) if not lane.enumerated: return lane diff --git a/src/gpt_voicecoding/adapters/agent/codex/discovery.py b/src/gpt_voicecoding/adapters/agent/codex/discovery.py index 4d100a1..4ef55a0 100644 --- a/src/gpt_voicecoding/adapters/agent/codex/discovery.py +++ b/src/gpt_voicecoding/adapters/agent/codex/discovery.py @@ -31,10 +31,13 @@ import logging import os from collections.abc import Awaitable, Callable, Mapping +from dataclasses import replace from datetime import UTC, datetime from pathlib import Path from typing import Any, Final, Protocol +from gpt_voicecoding.adapters.agent import _naming +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.adapters.agent.codex import rollouts, thread_tail from gpt_voicecoding.adapters.agent.codex.processes import Candidate, enumerate_sessions from gpt_voicecoding.seams.agent import ( @@ -69,6 +72,15 @@ "systemError": SessionState.IDLE, } +#: How much of a thread id stands in for a name the daemon does not have. Eight +#: characters of a UUID, which is what `codex` itself shows and short enough to +#: say out loud — the fallback task of every unnamed thread (#78). It is +#: composed here, from a fact this lane already holds: nothing is asked of the +#: Session, and the route that used to ask one (`legacy@1d32845:bridge/hook.py: +#: 215-253`, `bridge/daemon.py:1504-1544`) was *dropped* from the #67 port table +#: on 2026-08-25. +SHORT_THREAD_ID_CHARACTERS: Final = 8 + #: What every degraded reading ends with: where the rows actually came from. #: The *reason* is a separate sentence, because there are two of them and they #: are not the same fact. @@ -208,6 +220,7 @@ async def discover( home: Path | None = None, turns: TurnCache | None = None, daemon_note: str = "", + projects: ProjectNames | None = None, ) -> LaneDiscovery: """Every Codex Session on this machine, however well it can be described. @@ -221,6 +234,7 @@ async def discover( return LaneDiscovery(error=f"the process table could not be read: {unreadable}") threads, daemon_error = await _threads(client) + names = projects or ProjectNames() rows: list[SessionInspection] = [] claimed: set[int] = set() @@ -233,15 +247,15 @@ async def discover( if turns is not None and client is not None else None ) - rows.append(_from_thread(thread, pid, progress)) + rows.append( + await _named(_from_thread(thread, pid, progress), names, task=_thread_name(thread)) + ) if turns is not None: turns.retain({str(thread.get("id")) for thread in threads}) - rows.extend( - _from_process(candidate, home=home) - for candidate in candidates - if candidate.pid not in claimed - ) + for candidate in candidates: + if candidate.pid not in claimed: + rows.append(await _named(_from_process(candidate, home=home), names)) return LaneDiscovery(rows=tuple(rows), degraded=_degraded(daemon_error, daemon_note)) @@ -372,7 +386,6 @@ def _from_thread( kind = _status_of(thread) state = STATUS_TYPES.get(str(kind), SessionState.RUNNING) cwd = thread.get("cwd") - name = thread.get("name") return SessionInspection( target=SessionTarget(agent=AgentKind.CODEX, session_id=str(thread["id"]), pid=pid), workspace=Path(str(cwd)) if isinstance(cwd, str) and cwd.strip() else Path(), @@ -390,10 +403,46 @@ def _from_thread( # the thread's own account of when it last moved, which is exactly the # case `last_activity` exists to answer when nothing was said (#76). last_activity=thread_tail.last_activity(thread), - name=name.strip() if isinstance(name, str) and name.strip() else None, ) +def _thread_name(thread: Mapping[str, Any]) -> str | None: + """What the daemon calls this thread, when it calls it anything.""" + name = thread.get("name") + return name.strip() if isinstance(name, str) and name.strip() else None + + +async def _named( + row: SessionInspection, projects: ProjectNames, *, task: str | None = None +) -> SessionInspection: + """The same row, carrying its Session Name. + + One rule for both sources, because there is one kind of Codex Session: the + task is the daemon's `Thread.name` when the daemon has one, and the first + `SHORT_THREAD_ID_CHARACTERS` of the thread id when it does not. A row read + off the process table simply never has the first, so it takes the second — + and a Session that has not taken its first turn has neither, because it has + no thread id yet (#73), so it stays unnamed until it does. That is not a + reach problem: an unnamed row is listed, and what it can be addressed by is + its target, which it has had all along. + """ + chosen = task or _short_thread_id(row.target.session_id) + if chosen is None: + return row + project = await projects.of(row.workspace) + if project is None: + return row + return replace(row, name=_naming.compose(project, chosen)) + + +def _short_thread_id(thread_id: str | None) -> str | None: + """The head of a thread id, as a name for a thread nobody named.""" + if thread_id is None: + return None + short = thread_id.strip()[:SHORT_THREAD_ID_CHARACTERS] + return short or None + + def _from_process(candidate: Candidate, *, home: Path | None) -> SessionInspection: """One running TUI the daemon does not hold, named as well as disk allows. diff --git a/src/gpt_voicecoding/control_plane/commands.py b/src/gpt_voicecoding/control_plane/commands.py index 6db6d81..32c7b7f 100644 --- a/src/gpt_voicecoding/control_plane/commands.py +++ b/src/gpt_voicecoding/control_plane/commands.py @@ -7,10 +7,10 @@ Two shapes are worth naming: -- **An address, not a label.** `codex:abc` and `claude:def:1234` are how a - surface writes a `SessionTarget` on one line. A Session Label is for speech and +- **An address, not a name.** `codex:abc` and `claude:def:1234` are how a + surface writes a `SessionTarget` on one line. A Session Name is for speech and for matching, and turning one into a target is the router's job on the way in - — never this parser's, which would be addressing by label through the back + — never this parser's, which would be addressing by name through the back door. - **A refusal is rendered verbatim.** Bridge Core's words come back unchanged; this file never rephrases one. Honest wording lives in one place, and a @@ -191,7 +191,7 @@ def _roster_lines(sessions: object) -> list[str]: if not sessions: return ["sessions: none"] return ["sessions:"] + [ - f" {session['label'] or session['name'] or '(unnamed)'} — " + f" {session['name'] or '(unnamed)'} — " f"{format_address(session['target'])} — {session['workspace']} " f"({session['state']}, window {session['reply_window']})" for session in sessions diff --git a/src/gpt_voicecoding/control_plane/payloads.py b/src/gpt_voicecoding/control_plane/payloads.py index 12542ac..bd65ba3 100644 --- a/src/gpt_voicecoding/control_plane/payloads.py +++ b/src/gpt_voicecoding/control_plane/payloads.py @@ -6,10 +6,10 @@ refusal carries the refusal's own words. The reference implementation's expensive habit was a surface deciding what a result "really meant" on the user's behalf. -- **A label is never an address.** `SessionTarget` is what crosses the wire, and +- **A Session Name is never an address.** `SessionTarget` is what crosses the wire, and it is read back through the same constructor Bridge Core uses, so a Claude target without a pid is refused here rather than becoming an ambiguous - command later. Turning a spoken label into a target is the router's job, on + command later. Turning a spoken name into a target is the router's job, on the way in from the Companion Channel. Every reader raises `InvalidPayload`, which the action layer turns into one @@ -75,7 +75,7 @@ def read_flag(payload: Mapping[str, Any], key: str) -> bool: def read_target(payload: Mapping[str, Any], key: str = "target") -> SessionTarget: - """The exact identity a command carries. Never a label, never inferred.""" + """The exact identity a command carries. Never a name, never inferred.""" raw = payload.get(key) if not isinstance(raw, dict): raise InvalidPayload(f"{key!r} must name a Session: agent, session_id and pid") @@ -145,8 +145,11 @@ def session_document(session: Session) -> dict[str, Any]: """ return { "target": target_document(session.target), - "label": str(session.label) if session.label is not None else None, - "name": session.name, + # One name, rendered as the one string the user hears and types after + # `@` — ` · `. #78 collapsed the `label`/`name` pair + # that used to travel here into it; a surface that wants the halves + # parses them back with `SessionName.parse`. + "name": str(session.name) if session.name is not None else None, "workspace": str(session.workspace), "first_seen": session.first_seen, "lifecycle": str(session.lifecycle), diff --git a/src/gpt_voicecoding/core/__init__.py b/src/gpt_voicecoding/core/__init__.py index 029fd0f..5556d81 100644 --- a/src/gpt_voicecoding/core/__init__.py +++ b/src/gpt_voicecoding/core/__init__.py @@ -18,8 +18,8 @@ - ``switches`` — the Duty / Voice / Message hierarchy and the Feature Switches configuration declares beneath it. -- ``sessions`` — the Session registry: exact targets, stale ones refused, labels - that disambiguate or ask. +- ``sessions`` — the Session registry: exact targets, stale ones refused, Session + Names that disambiguate or ask. - ``relay_queue`` — the *one* ledger of everything undelivered, retained Stop Notices included. - ``persistence`` — the durable subset, and the only component that touches disk. diff --git a/src/gpt_voicecoding/core/bridge.py b/src/gpt_voicecoding/core/bridge.py index fd0265f..eeb1523 100644 --- a/src/gpt_voicecoding/core/bridge.py +++ b/src/gpt_voicecoding/core/bridge.py @@ -51,7 +51,7 @@ from gpt_voicecoding.core.relay_queue import PendingRelay from gpt_voicecoding.core.relays import RelayOutcome, RelayPipeline from gpt_voicecoding.core.router import Classification, InboundClass, InboundRouter, TextGrammar -from gpt_voicecoding.core.sessions import Session +from gpt_voicecoding.core.sessions import Session, spoken_name, spoken_target from gpt_voicecoding.core.state import BridgeState from gpt_voicecoding.core.switches import SwitchSnapshot from gpt_voicecoding.core.verification import ( @@ -104,20 +104,6 @@ NO_DELEGATE_HANDLER = "I can't take a delegated turn right now — nothing is wired to answer it" -def name_for(session: Session | None, target: SessionTarget) -> str: - """What to call one Session out loud, best name first. - - The Session Label if the user has one for it, else the agent's own Session - Name, else the address — because a notice that names nothing is a notice the - user cannot answer. #78 stabilises the middle one. - """ - if session is not None and session.label is not None: - return str(session.label) - if session is not None and session.name: - return session.name - return f"{target.agent} {target.session_id or f'pid {target.pid}'}" - - def stop_notice_for( session: Session | None, target: SessionTarget, waiting_for: WaitingFor | None = None ) -> str: @@ -131,7 +117,10 @@ def stop_notice_for( """ stopped_on = _stopped_on(waiting_for) if waiting_for is not None else "" tail = f" — {stopped_on}" if stopped_on else "" - return f"{name_for(session, target)} stopped and may need you{tail}" + # What to call it is `core/sessions.py`'s answer, and its floor is the + # address: a notice that names nothing is a notice the user cannot answer. + called = spoken_name(session) if session is not None else spoken_target(target) + return f"{called} stopped and may need you{tail}" def _state_behind(window: ReplyWindow, held: SessionState) -> SessionState: diff --git a/src/gpt_voicecoding/core/errors.py b/src/gpt_voicecoding/core/errors.py index e26ed9b..b43a12b 100644 --- a/src/gpt_voicecoding/core/errors.py +++ b/src/gpt_voicecoding/core/errors.py @@ -1,7 +1,7 @@ """What Bridge Core raises when it refuses. Every error here is a *refusal*, not a surprise: the state components fail closed -on an unknown switch, an unknown or stale Session, or an ambiguous label, because +on an unknown switch, an unknown or stale Session, or an ambiguous name, because the alternative — guessing — is the failure class this repository exists to avoid. Callers that must speak the refusal aloud get the offending value back on the exception rather than having to parse a message. @@ -113,11 +113,11 @@ def __init__(self, target: SessionTarget) -> None: self.target = target -class LabelMatchError(BridgeCoreError): - """Base for a label that did not resolve to exactly one Session.""" +class NameMatchError(BridgeCoreError): + """Base for a Session Name that did not resolve to exactly one Session.""" -class NoLabelMatchError(LabelMatchError): +class NoNameMatchError(NameMatchError): """Nothing matched. Ask; do not guess.""" def __init__(self, query: str) -> None: @@ -125,7 +125,7 @@ def __init__(self, query: str) -> None: self.query = query -class AmbiguousLabelError(LabelMatchError): +class AmbiguousNameError(NameMatchError): """More than one matched. Refuse and name them, rather than picking one.""" def __init__(self, query: str, candidates: tuple[Session, ...]) -> None: diff --git a/src/gpt_voicecoding/core/instructions/catalogue.py b/src/gpt_voicecoding/core/instructions/catalogue.py index 41184c3..7be8811 100644 --- a/src/gpt_voicecoding/core/instructions/catalogue.py +++ b/src/gpt_voicecoding/core/instructions/catalogue.py @@ -179,11 +179,11 @@ def _rules() -> tuple[Rule, ...]: ), ), Rule( - id="voice.identity.speak-labels", + id="voice.identity.speak-names", audience=Audience.VOICE, source="skill/SKILL.md:56-62", gist=( - "Sessions are spoken about by Session Label. A position — 'the third one' — " + "Sessions are spoken about by Session Name. A position — 'the third one' — " "refers to the row just read out, and is resolved to that row's identity " "before anything acts." ), @@ -194,9 +194,9 @@ def _rules() -> tuple[Rule, ...]: source="skill/SKILL.md:56-62", gist=( "The identity a Session is addressed by is structured and machine-side; a " - "Session Label is not a target and cannot be passed as one." + "Session Name is not a target and cannot be passed as one." ), - enforced_by="SessionLabel and SessionTarget are different types — seams/identity.py", + enforced_by="SessionName and SessionTarget are different types — seams/identity.py", ), Rule( id="delegated.outcome.only-a-successful-call-is-success", @@ -259,7 +259,7 @@ def _rules() -> tuple[Rule, ...]: source="skill/announcing.md:1-11", gist=( "A Stop Notice is spoken as ordinary sentences in the user's language, " - "naming the Session by its label and the task it is on." + "naming the Session by its name and the task it is on." ), ), Rule( @@ -325,7 +325,7 @@ def _rules() -> tuple[Rule, ...]: audience=Audience.VOICE, source="skill/announcing.md:64-94", gist=( - "Label and state first, then the question in one sentence, then each option " + "Name and state first, then the question in one sentence, then each option " "by name, then whose recommendation it is, then what is needed from the " "user — and when the detail is known to be stale, say the gap out loud." ), @@ -555,14 +555,14 @@ def _rules() -> tuple[Rule, ...]: ), ), Rule( - id="core.start.exact-identity-until-a-label-exists", + id="core.start.exact-identity-until-a-name-exists", audience=Audience.CORE, source="skill/starting.md:108-126", gist=( "A Session is addressable by its exact identity from the moment it " - "registers, whether or not it has a Session Label yet." + "registers, whether or not it has a Session Name yet." ), - enforced_by="the Session registry's label-independent targets — core/sessions.py", + enforced_by="the Session registry's name-independent targets — core/sessions.py", ), Rule( id="dropped.starting.duty-gated-reads", diff --git a/src/gpt_voicecoding/core/instructions/voice.py b/src/gpt_voicecoding/core/instructions/voice.py index 0879c78..87354df 100644 --- a/src/gpt_voicecoding/core/instructions/voice.py +++ b/src/gpt_voicecoding/core/instructions/voice.py @@ -92,9 +92,9 @@ def _sections(context: InstructionContext) -> tuple[Section, ...]: title="Naming what is running", blocks=( Block( - covers=("voice.identity.speak-labels",), + covers=("voice.identity.speak-names",), text=( - "Every session has a Session Label — its project and its task — and " + "Every session has a Session Name — its project and its task — and " "that is what you say out loud, the way a person would say it in a " "sentence. Machine identities stay inside your commands; hearing one " 'tells the user nothing. When they point by position — "the third ' @@ -183,8 +183,8 @@ def _sections(context: InstructionContext) -> tuple[Section, ...]: text=( "When a session stops and needs the user, you are the announcement. " "Speak ordinary sentences in the language they are speaking, naming " - "the session by its label and what it was working on. Several sessions " - "can stop within a minute, and the label is how they know which news " + "the session by its name and what it was working on. Several sessions " + "can stop within a minute, and the name is how they know which news " "this is." ), ), diff --git a/src/gpt_voicecoding/core/router.py b/src/gpt_voicecoding/core/router.py index cacb79e..10c5e86 100644 --- a/src/gpt_voicecoding/core/router.py +++ b/src/gpt_voicecoding/core/router.py @@ -15,13 +15,13 @@ / … a control-plane command > a Delegated Turn - @