Files
LLeMbas-CLI/src/session/prefix.ts
T
HomerandClaude Opus 5.5 6ec95fbd66
ci / check (push) Waiting to run
Teams in the CLI (harness spec 3.2.0, team.md)
The four team tools (tool/team_tools.ts) on the rules of team/team.ts,
driven from real turns by team/runtime.ts:

- Members run on the subagent job machinery (app.ts spawn with `member`):
  background, unattended, their own prompt from blocks/team_member.md
  (the whole of team_lead.md for one with lead: true), team_send and
  team_read (team_add and team_stop for one that leads). Not jobs to the
  model: job_list and the job_* tools skip them; a client over the link
  still sees them as subagent jobs named "<name>: <role>".
- The room: messages and reports with their marks, delivered at the next
  step boundary or waking a finished member or the idle lead; report
  posts to the manager.
- A manager's work calls answer the refusal before the permission check,
  by risk class (an MCP tool by its readOnlyHint); the tool list is
  unchanged. job_stop and reading bash stay.
- Slots: the lead waits for one before its turn (Esc stops the wait);
  members wait in order; task helpers in a team count toward the size
  and hold a slot; job_send restarting a helper is refused when full.
- The offer is decided with the prompt and kept with it: a resumed
  session takes it from its kept tool list.
- The size: agents.min/max settings, refused outside 1 <= min <= max <=
  cap, kept across /new and resume; said in a note on the first message,
  after a change, after a compaction and a rewind.
- TUI: /team (tree and room, size, stop a member, dialogs for both), an
  "agents" row beside the effort in /effort, "team n/max" in the status
  bar. /team works over ACP too.

tests/team.test.ts runs a lead, a member and its report through the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:08:52 +00:00

158 lines
7.7 KiB
TypeScript

// The prefix (harness spec 3.1.0, context.md §10): what the model has already read stays exactly as
// it was, so its server can reuse it. The system prompt and the tool list are made once per session
// and resent byte for byte; what changes during the session goes as a note on the next user
// message. This file holds the rules and the texts, without the engine: what the model was last
// told, what a note says about the difference, which tools are offered, what a call answers when
// its requirement is not met, and what compaction asks for. The engine keeps the state
// (session/engine.ts); the conformance area `prefix` drives these functions directly.
import type { Mode } from "../config/schema.ts"
import { LOOP, TOOL_SPECS } from "../harness.ts"
import { fillPrompt } from "../prompt/fill.ts"
import { fill, promptText } from "../prompt/assemble.ts"
/** The note's texts (loop.json `context.note`). */
export const NOTE = LOOP.context.note
/** What a note says in place of something that is now empty: no plan, no memory, a file removed. */
export const NOTHING = NOTE.empty
/** The session_meta key the prefix is kept under (the prompt, the tools left out, what was told). */
export const PREFIX_META = "prefix"
/** What the system prompt holds that can change during a session: the app reads it as it is now,
* builds the prompt from it, and the engine compares it at every user message. Each is "" (or
* empty) where there is none. */
export interface Changing {
/** The memory block as the prompt shows it. */
memory: string
/** The personality as the prompt shows it. */
personality: string
/** Instruction texts by what names them: an instruction file by its path; the person's own
* instructions and an instance's by their heading. */
files: Record<string, string>
/** MCP servers' instructions, by server. */
mcp: Record<string, string>
/** What the app needs to make the prompt from these same values; never compared or kept. */
source?: unknown
}
export const NO_CHANGING: Changing = { memory: "", personality: "", files: {}, mcp: {} }
/** What the model was last told of each: by the system prompt when it was made, by a note since, or
* by its own tool's result (a plan it wrote, a mode its plan_submit set). `date` is absent until a
* note has said it. */
export interface Told extends Changing {
date?: string
mode: Mode
/** The plan as rendered (plan.ts `render`), or "". */
plan: string
}
/** What is true now, against which a Told is compared. */
export interface Now extends Changing {
date: string
mode: Mode
plan: string
}
/** The prefix a session keeps (session_meta `prefix`): the prompt it sends, the tools left out of
* its list for the whole session (every other tool is offered, MCP tools as their servers
* connect), the model it was made for, and what the model has been told. */
export interface Kept {
system: string
left: string[]
model: string
told: Told
}
/** Told as of a prompt made now: everything as it is, except the date, which no prompt holds. */
export function toldAt(now: Now): Told {
const { date: _date, source: _source, ...rest } = now
return structuredClone(rest)
}
/** The note for a user message: a line for each thing that differs from what the model was told,
* in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's
* text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string, extra: string[] = []): { note: string; told: Told } {
const lines: string[] = []
const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
const or = (s: string) => (s.trim() ? s : NOTHING)
if (told.date !== now.date) say(NOTE.date, { date: now.date })
if (told.mode !== now.mode) say(NOTE.mode, { mode: now.mode, mode_text: modeText(now.mode) })
if (told.plan !== now.plan) say(NOTE.plan, { plan: or(now.plan) })
if (told.memory !== now.memory) say(NOTE.memory, { memory: or(now.memory) })
for (const path of union(told.files, now.files))
if (told.files[path] !== now.files[path]) say(NOTE.instructions, { path, text: or(now.files[path] ?? "") })
if (told.personality !== now.personality) say(NOTE.personality, { personality: or(now.personality) })
// Only a server that has connected or changed since: one that went away takes its tools with it.
const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name])
if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") })
// Spec 3.2.0: the team's size and members the person stopped (team.md §10), last.
lines.push(...extra)
return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } }
}
/** The block a note is: its lines between the tags, a blank line between two. */
export function noteBlock(lines: string[]): string {
return `${NOTE.open}\n${lines.join("\n\n")}\n${NOTE.close}`
}
/** Whether a text part of a user message is a note (not shown as the person's words). */
export function isNote(text: string): boolean {
return text.startsWith(NOTE.open) && text.trimEnd().endsWith(NOTE.close)
}
/** One MCP server's instructions as the prompt and a note show them. */
export function mcpBlock(name: string, text: string): string {
return `<server name="${name}">\n${text}\n</server>`
}
function union(a: Record<string, string>, b: Record<string, string>): string[] {
return [...new Set([...Object.keys(a), ...Object.keys(b)])]
}
/** Today's date where this runs, as the note says it (YYYY-MM-DD). */
export function today(at = new Date()): string {
const p = (n: number) => String(n).padStart(2, "0")
return `${at.getFullYear()}-${p(at.getMonth() + 1)}-${p(at.getDate())}`
}
/** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project,
* knowledge). */
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge" | "team"
/** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has
* is one the session could meet at some point (`could`), decided once as the prompt is made; a
* call while one is not met now answers `unavailable`. */
export function offeredFor(requires: readonly Requirement[] | undefined, could: ReadonlySet<Requirement>): boolean {
return (requires ?? []).every((r) => could.has(r))
}
/** What a call answers when a requirement of its tool is not met now: the spec's text, word for
* word, or undefined when it is met (or the tool has no such text). */
export function unavailable(tool: string, requires: readonly Requirement[] | undefined, met: ReadonlySet<Requirement>): string | undefined {
if ((requires ?? []).every((r) => met.has(r))) return undefined
return TOOL_SPECS[tool]?.unavailable
}
/** §7: what the summary is asked with, as one more user message after the conversation —
* tasks/compact.md with `{{transcript}}` and `{{previous_summary}}` empty, since the model has the
* conversation, an earlier summary included, itself. */
export function compactAsk(): string {
return fill(promptText("tasks/compact.md"), { previous_summary: "", transcript: "" })
.replace(/\n{3,}/g, "\n\n")
.trim()
}
/** The summary in a reply to the compaction request: its text before any tool call (a call in it
* is not run), "" when there is none. */
export function summaryOf(parts: { type: string; text?: string }[]): string {
const first = parts.findIndex((p) => p.type === "tool_call")
return (first < 0 ? parts : parts.slice(0, first))
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("")
.trim()
}