Harness spec 1.1.0: one plan, prototypes and the library shared; the CLI implements it
The spec: plan_write and plan_update (new, shared, offered in every mode) and plan_submit (now shared) keep one structured plan — findings, objectives, phases of tasks — in place of the todo list, which lives on as a former name of plan_write. The shared system text is one text for both projects, with variables for what differs; the mode texts, a prototypes block and a library block are shared. acp.md: the client hands the device what only it knows (context, library, prototypes), the plan travels as a structure (plan_state), library calls carry the chat's scope, and a client may rewind a session. loop.json says its numbers as numbers. Four conformance areas: plan, prototype, library_scope, toolcall_index. The CLI: the plan is kept per session in the store and drawn above the prompt and on the approval card; the prompt is assembled from the spec's texts with their variables filled; the link reads the instance's instructions, personalization, knowledge scope and prototype offer, sends the plan as a structure and answers rewind; library calls to the instance carry the session as scope; a prototype written in the terminal is saved as a file and named in the transcript; a tool-call fragment without an index continues the last call; the default theme is Moria; dead exports and Hermes leftovers removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
96 files changed
+3259
-527
No files matched your search
+116
-32
@@ -13,16 +13,19 @@
|
||||
// block: a directory outside `roots` is refused, a mode beyond `max_mode` is lowered to it, an
|
||||
// untrusted project is refused, and an approval nobody answers in time is a denial. The hardline
|
||||
// applies in every mode, as everywhere.
|
||||
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"
|
||||
import { existsSync, readdirSync, statSync } from "node:fs"
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { createApp, type App } from "../app.ts"
|
||||
import { createApp, type App, type SessionContext } from "../app.ts"
|
||||
import type { AskReply, Asker, Event } from "../bus/index.ts"
|
||||
import { FileIndex } from "../project/files.ts"
|
||||
import { loadSkills } from "../skill/index.ts"
|
||||
import { commandSet, sourcesOf, type CommandInfo } from "../session/commands.ts"
|
||||
import { loadConfig } from "../config/load.ts"
|
||||
import { NOBODY_TO_ASK, type Answer, type QuestionReply, type QuestionRequest } from "../tool/question.ts"
|
||||
import { nobodyToApprove, type PlanReply } from "../tool/plan_exit.ts"
|
||||
import { NOBODY_TO_ASK, type Answer, type QuestionReply, type QuestionRequest } from "../tool/ask_user.ts"
|
||||
import { nobodyToApprove, type PlanReply, type PlanRequest } from "../tool/plan_submit.ts"
|
||||
import { realPath } from "../permission/evaluate.ts"
|
||||
import type { Plan } from "../plan/plan.ts"
|
||||
import { PLAN_META } from "../session/engine.ts"
|
||||
import { ToolError } from "../tool/tool.ts"
|
||||
import { dropAttachments, preparePrompt, storedAttachments } from "./prompt.ts"
|
||||
import { addPendingDeleted, clearPendingDeleted, pendingDeleted } from "./pending.ts"
|
||||
@@ -73,7 +76,7 @@ export function refusalFor(L: Limits | undefined, dir: string): string | undefin
|
||||
if (L.enabled === false) return "remote work is off on this machine — lembas remote enable there (remote.enabled)"
|
||||
if (!L.roots.length) return "this device allows no folders for remote work yet — lembas remote add <dir> there (remote.roots)"
|
||||
if (!underRoots(dir, L.roots)) return `${dir} is outside the directories this device accepts remote work in (remote.roots)`
|
||||
if (L.requireTrust && trustOf(findProject(dir).root) !== "trusted" && !trustedWithin(real(dir)))
|
||||
if (L.requireTrust && trustOf(findProject(dir).root) !== "trusted" && !trustedWithin(realPath(dir)))
|
||||
return `${dir} is not in a trusted project on this device — trust it there first (lembas trust)`
|
||||
return undefined
|
||||
}
|
||||
@@ -136,20 +139,11 @@ export function clampMode(wanted: Mode, max: Mode): Mode {
|
||||
return MODE_RANK[wanted] < MODE_RANK[max] ? max : wanted
|
||||
}
|
||||
|
||||
/** A path with its symlinks followed, where it exists: a link inside a root that points out of it
|
||||
* is outside. */
|
||||
function real(p: string): string {
|
||||
try {
|
||||
return realpathSync(p)
|
||||
} catch {
|
||||
return resolve(p)
|
||||
}
|
||||
}
|
||||
|
||||
export function underRoots(dir: string, roots: string[]): boolean {
|
||||
const target = real(dir)
|
||||
// Symlinks followed (realPath), so a link inside a root that points out of it is outside.
|
||||
const target = realPath(dir)
|
||||
return roots.some((r) => {
|
||||
const root = real(expandHome(r))
|
||||
const root = realPath(expandHome(r))
|
||||
const rel = relative(root, target)
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
|
||||
})
|
||||
@@ -199,6 +193,41 @@ function promptText(blocks: unknown): string {
|
||||
const turnStore = (sessionId: string) => sessionTurnFile<PromptResult>(sessionId)
|
||||
const turnLog = (sessionId: string) => sessionTurns<PromptResult>(sessionId)
|
||||
|
||||
/** `Context` (harness/acp.md): what the client holds for a session, read from `_meta.lembas` of
|
||||
* `session/new` and `session/prompt`. Undefined when none of its fields is there (a prompt's
|
||||
* `_meta.lembas` with a turnId alone changes nothing); fields the client leaves out are unset. */
|
||||
export function contextOf(meta: any): SessionContext | undefined {
|
||||
const m = meta?.lembas
|
||||
if (!m || typeof m !== "object") return undefined
|
||||
const out: SessionContext = {}
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : "")
|
||||
if (typeof m.system_prompt === "string") out.instructions = m.system_prompt
|
||||
if (m.personalization === null) out.personalization = null
|
||||
else if (m.personalization && typeof m.personalization === "object")
|
||||
out.personalization = { personality: str(m.personalization.personality), personality_custom: str(m.personalization.personality_custom), instructions: str(m.personalization.instructions) }
|
||||
if (m.library && typeof m.library === "object")
|
||||
out.library = {
|
||||
bases: (Array.isArray(m.library.bases) ? (m.library.bases as unknown[]) : [])
|
||||
.filter((b): b is { id: unknown; name: unknown } => !!b && typeof b === "object")
|
||||
.map((b) => ({ id: str(b.id), name: str(b.name) }))
|
||||
.filter((b) => b.id || b.name)
|
||||
.map((b) => ({ id: b.id || b.name, name: b.name || b.id })),
|
||||
}
|
||||
if (m.prototypes && typeof m.prototypes === "object") out.prototypes = { offered: m.prototypes.offered === true, sources: str(m.prototypes.sources) }
|
||||
return Object.keys(out).length ? out : undefined
|
||||
}
|
||||
|
||||
/** The `plan` session update: ACP's flat entries for an editor, the structure for the web UI
|
||||
* (`_meta.lembas.plan`, null when cleared). */
|
||||
export function planUpdate(plan: Plan | null | undefined) {
|
||||
const entries = (plan?.phases.flatMap((p) => p.tasks) ?? []).map((t) => ({
|
||||
content: t.text,
|
||||
status: t.status === "done" || t.status === "dropped" ? "completed" : t.status === "doing" ? "in_progress" : "pending",
|
||||
priority: "medium",
|
||||
}))
|
||||
return { sessionUpdate: "plan", entries, _meta: { lembas: { plan: plan ?? null } } }
|
||||
}
|
||||
|
||||
/** The turn id the web UI gave a prompt, or a new one. */
|
||||
export function turnIdOf(p: any): string {
|
||||
const id = p?._meta?.lembas?.turnId
|
||||
@@ -229,6 +258,14 @@ export const CAPABILITIES = [
|
||||
"plan",
|
||||
"model",
|
||||
"turns",
|
||||
// Harness spec 1.1.0: what only the client knows comes with the session (its instructions,
|
||||
// personalization, library scope, prototypes), the plan travels as a structure, library calls
|
||||
// carry the chat's scope, and a client may rewind a session.
|
||||
"context",
|
||||
"library",
|
||||
"prototypes",
|
||||
"plan_state",
|
||||
"rewind",
|
||||
] as const
|
||||
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
||||
|
||||
@@ -278,9 +315,10 @@ export function questionParams(sessionId: string, req: QuestionRequest, toolCall
|
||||
return { sessionId, toolCallId: toolCallId ?? crypto.randomUUID(), ...(req.why ? { why: req.why } : {}), questions, _meta: { lembas: o.patient ? { patient: true } : {} } }
|
||||
}
|
||||
|
||||
/** `_lembas/plan`'s params: the plan_submit card, with the plan's markdown. */
|
||||
export function planParams(sessionId: string, req: { path: string; text: string }, toolCallId: string | undefined, o: { patient?: boolean } = {}) {
|
||||
return { sessionId, toolCallId: toolCallId ?? crypto.randomUUID(), path: req.path, text: req.text, _meta: { lembas: o.patient ? { patient: true } : {} } }
|
||||
/** `_lembas/plan`'s params: the plan_submit card, with the plan's markdown and its structure
|
||||
* (capability `plan_state`; a client without it draws `text`). */
|
||||
export function planParams(sessionId: string, req: PlanRequest, toolCallId: string | undefined, o: { patient?: boolean } = {}) {
|
||||
return { sessionId, toolCallId: toolCallId ?? crypto.randomUUID(), path: req.path, text: req.text, plan: req.plan, _meta: { lembas: o.patient ? { patient: true } : {} } }
|
||||
}
|
||||
|
||||
/** The client's answer to `_lembas/question`, as the tool's reply. Anything else is a dismissal. */
|
||||
@@ -400,6 +438,7 @@ export class AcpAgent {
|
||||
peer.handle("_lembas/session/delete", (p) => this.deleteSession(p))
|
||||
peer.handle("_lembas/session/compact", (p) => this.compact(p))
|
||||
peer.handle("_lembas/session/title", (p) => this.retitle(p))
|
||||
peer.handle("_lembas/session/rewind", (p) => this.rewind(p))
|
||||
peer.handle("_lembas/terminal/open", (p) => this.terminalOpen(p))
|
||||
peer.handle("_lembas/files/search", (p) => this.filesSearch(p))
|
||||
peer.handle("_lembas/commands", (p) => this.commands(p))
|
||||
@@ -526,10 +565,12 @@ export class AcpAgent {
|
||||
session.id = id
|
||||
session.turns = turnLog(id)
|
||||
markRemote(session.app.store, id)
|
||||
this.takeContext(session, p)
|
||||
this.sessions.set(id, session)
|
||||
this.adopt(session)
|
||||
// After the answer that names the session, so the client knows whose commands they are.
|
||||
setTimeout(() => this.sendCommands(session, true), 0)
|
||||
// After the answer that names the session, so the client knows whose commands they are
|
||||
// (and whose plan, where the session has one).
|
||||
setTimeout(() => (this.sendCommands(session, true), this.sendPlan(session)), 0)
|
||||
return {
|
||||
sessionId: id,
|
||||
modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) },
|
||||
@@ -600,10 +641,25 @@ export class AcpAgent {
|
||||
if (L) session.app.engine.mode = clampMode(session.app.engine.mode, L.maxMode)
|
||||
this.sessions.set(id, session)
|
||||
this.adopt(session)
|
||||
setTimeout(() => this.sendCommands(session, true), 0)
|
||||
setTimeout(() => (this.sendCommands(session, true), this.sendPlan(session)), 0)
|
||||
return session
|
||||
}
|
||||
|
||||
/** The client's context for the session (`_meta.lembas` of session/new or session/prompt),
|
||||
* the newer replacing the older. */
|
||||
private takeContext(s: Session, p: any) {
|
||||
const ctx = contextOf(p?._meta)
|
||||
if (ctx) s.app.setContext(ctx, this.instanceConnection)
|
||||
}
|
||||
|
||||
/** The session's plan, as a `plan` update — after session/new and after opening from the
|
||||
* store, when it has one (a change is forwarded as it happens). */
|
||||
private sendPlan(s: Session) {
|
||||
if (!this.ready || this.peer.isClosed || s.agent !== this) return
|
||||
const plan = s.app.engine.plan
|
||||
if (plan) this.update(s, planUpdate(plan))
|
||||
}
|
||||
|
||||
/** This link passes the session's events on, and asks its approvals. */
|
||||
private adopt(s: Session) {
|
||||
s.off?.()
|
||||
@@ -817,12 +873,14 @@ export class AcpAgent {
|
||||
throw new RpcError(INVALID_PARAMS, `this session runs in ${t.mode} in the terminal, beyond what remote work may use here (${L.maxMode}) — change its mode there, or share it by name with /remote`)
|
||||
// The blocks themselves: the terminal expands commands, @files and attachments as
|
||||
// its own keyboard would; `text` for a terminal of a build before that.
|
||||
return t.client.request("session/prompt", { sessionId: t.sessionId, text: promptText(p.prompt), prompt: p.prompt, turnId })
|
||||
const context = contextOf(p?._meta)
|
||||
return t.client.request("session/prompt", { sessionId: t.sessionId, text: promptText(p.prompt), prompt: p.prompt, turnId, ...(context ? { context, instance: this.instanceConnection } : {}) })
|
||||
}
|
||||
const s = this.get(p)
|
||||
// A turn id this session has taken already: not run again — the original's answer,
|
||||
// now or when it is done.
|
||||
if (s.turns.has(turnId)) return s.turns.run(turnId, () => Promise.reject(new Error("unreachable")))
|
||||
this.takeContext(s, p)
|
||||
const prepared = preparePrompt(s.app, s.id, p.prompt, { roots: this.limits?.roots })
|
||||
// Working already: the prompt waits for the turn to end and runs next. It used to be
|
||||
// refused, and a web UI that had misjudged the session as idle lost its message to an error.
|
||||
@@ -905,9 +963,9 @@ export class AcpAgent {
|
||||
if (t) {
|
||||
// The terminal knows its turns; what the hub saw of them, when it cannot be asked.
|
||||
const turns = await t.client
|
||||
.request<{ turnId?: string; queued?: string[]; lastTurnId?: string }>("hub/status", { sessionId: t.sessionId }, 3000)
|
||||
.catch(() => ({ ...(t.turnId ? { turnId: t.turnId } : {}), queued: [], ...(t.lastTurnId ? { lastTurnId: t.lastTurnId } : {}) }))
|
||||
return { sessionId: id, exists: true, held: "terminal", busy: t.busy, title: t.title, ...turns, queued: turns.queued ?? [] }
|
||||
.request<{ turnId?: string; queued?: string[]; lastTurnId?: string; plan?: Plan | null }>("hub/status", { sessionId: t.sessionId }, 3000)
|
||||
.catch(() => ({ ...(t.turnId ? { turnId: t.turnId } : {}), queued: [], ...(t.lastTurnId ? { lastTurnId: t.lastTurnId } : {}), plan: this.storedPlan(id) }))
|
||||
return { sessionId: id, exists: true, held: "terminal", busy: t.busy, title: t.title, ...turns, queued: turns.queued ?? [], plan: turns.plan ?? null }
|
||||
}
|
||||
const s = this.sessions.get(id)
|
||||
if (s)
|
||||
@@ -920,11 +978,37 @@ export class AcpAgent {
|
||||
...(s.turns.running ? { turnId: s.turns.running } : {}),
|
||||
queued: s.turns.queued(),
|
||||
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
|
||||
plan: s.app.engine.plan ?? null,
|
||||
}
|
||||
const row = (this.store ??= new Store()).session(id)
|
||||
// Not open: the last turn it finished, as kept across a restart.
|
||||
// Not open: the last turn it finished, as kept across a restart, and its plan as stored.
|
||||
const last = row ? turnStore(id).load().at(-1)?.[0] : undefined
|
||||
return { sessionId: id, exists: Boolean(row), held: "none", busy: false, title: row?.title ?? "", queued: [], ...(last ? { lastTurnId: last } : {}) }
|
||||
return { sessionId: id, exists: Boolean(row), held: "none", busy: false, title: row?.title ?? "", queued: [], ...(last ? { lastTurnId: last } : {}), plan: row ? this.storedPlan(id) : null }
|
||||
}
|
||||
|
||||
/** A stored session's plan (the engine keeps it under PLAN_META), or null. */
|
||||
private storedPlan(id: string): Plan | null {
|
||||
try {
|
||||
const p = (this.store ??= new Store()).meta<Plan | null>(id, PLAN_META)
|
||||
return p && typeof p === "object" && "version" in p ? p : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** `_lembas/session/rewind`: the client is editing or regenerating from a turn. That turn and
|
||||
* every later one leave the conversation and the history, as /undo drops a turn — the files
|
||||
* are not touched. Refused for a turn the session does not have, or while it works. */
|
||||
private async rewind(p: any) {
|
||||
const turnId = typeof p?.turnId === "string" ? p.turnId.trim() : ""
|
||||
if (!turnId) throw new RpcError(INVALID_PARAMS, "turnId, please")
|
||||
const t = this.terminalFor(p)
|
||||
if (t) return t.client.request("hub/rewind", { sessionId: t.sessionId, turnId }, 15_000)
|
||||
const s = this.get(p)
|
||||
if (s.prompting || s.turns.queued().length) throw new RpcError(INVALID_PARAMS, "this session is working — rewind it when the reply is done")
|
||||
const reverted = s.app.turns.rewind(turnId)
|
||||
if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`)
|
||||
return { reverted }
|
||||
}
|
||||
|
||||
/** `_lembas/session/delete`: the chat was deleted in the web UI, so the session is too —
|
||||
@@ -1101,8 +1185,8 @@ export class AcpAgent {
|
||||
})
|
||||
case "tool_denied":
|
||||
return this.update(s, { sessionUpdate: "tool_call_update", toolCallId: e.id, status: "failed", content: [{ type: "content", content: { type: "text", text: e.reason } }] })
|
||||
case "todos":
|
||||
return this.update(s, { sessionUpdate: "plan", entries: e.items.map((t: any) => ({ content: t.content ?? t.text ?? "", status: t.status === "done" || t.status === "completed" || t.status === "cancelled" ? "completed" : t.status === "in_progress" ? "in_progress" : "pending", priority: "medium" })) })
|
||||
case "plan":
|
||||
return this.update(s, planUpdate(e.plan))
|
||||
case "mode":
|
||||
return this.update(s, { sessionUpdate: "current_mode_update", currentModeId: e.mode })
|
||||
}
|
||||
@@ -1156,7 +1240,7 @@ export class AcpAgent {
|
||||
},
|
||||
get plan() {
|
||||
if (!canAsk()) return undefined
|
||||
return async (req: { path: string; text: string }, callId?: string): Promise<PlanReply> => {
|
||||
return async (req: PlanRequest, callId?: string): Promise<PlanReply> => {
|
||||
const agent = agentOf()
|
||||
const params = planParams(s.id, req, callId)
|
||||
try {
|
||||
|
||||
@@ -21,7 +21,6 @@ export class RpcError extends Error {
|
||||
}
|
||||
|
||||
export const PARSE_ERROR = -32700
|
||||
export const INVALID_REQUEST = -32600
|
||||
export const METHOD_NOT_FOUND = -32601
|
||||
export const INVALID_PARAMS = -32602
|
||||
export const INTERNAL_ERROR = -32603
|
||||
|
||||
+22
-4
@@ -12,9 +12,10 @@ import type { AskReply, Asker, AttachmentInfo, Event } from "../bus/index.ts"
|
||||
import { asMode, type Mode } from "../config/schema.ts"
|
||||
import type { ImagePart } from "../provider/types.ts"
|
||||
import { commandSet, sourcesOf } from "../session/commands.ts"
|
||||
import type { PlanReply } from "../tool/plan_exit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/question.ts"
|
||||
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
||||
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
|
||||
import type { SessionContext } from "../app.ts"
|
||||
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
|
||||
import { dropAttachments, preparePrompt } from "./prompt.ts"
|
||||
import { connectHub, Hub } from "./hub.ts"
|
||||
@@ -60,6 +61,8 @@ export interface ShareHooks {
|
||||
deleted(): void
|
||||
/** Shared or not, mode, model or effort changed: redraw. */
|
||||
changed(): void
|
||||
/** The web UI rewound the session (`_lembas/session/rewind`): the conversation is shorter now. */
|
||||
rewound?(): void
|
||||
}
|
||||
|
||||
let current: Share | undefined
|
||||
@@ -259,7 +262,7 @@ export class Share {
|
||||
}
|
||||
|
||||
/** plan_submit in the shared session, likewise. */
|
||||
plan(req: { path: string; text: string }, callId: string | undefined, local: LocalAsk<PlanReply>): Promise<PlanReply> {
|
||||
plan(req: PlanRequest, callId: string | undefined, local: LocalAsk<PlanReply>): Promise<PlanReply> {
|
||||
return this.card(local, "hub/plan", (id) => planParams(id, req, callId, { patient: true }), (a) => planReplyFrom(a), (r) => (r.kind === "approve" ? `approved (${r.mode})` : r.kind === "revise" ? "to be revised" : "not decided"))
|
||||
}
|
||||
|
||||
@@ -465,6 +468,9 @@ export class Share {
|
||||
const turnId = typeof p?.turnId === "string" && p.turnId ? p.turnId : crypto.randomUUID()
|
||||
// A turn id taken already: the original's answer.
|
||||
if (this.turns.has(turnId)) return this.turns.run(turnId, () => Promise.reject(new Error("unreachable")))
|
||||
// What the client holds for the session (its instructions, personalization, library scope,
|
||||
// prototypes), passed on by the hub: read into the prompt here as the service would.
|
||||
if (p?.context && typeof p.context === "object") this.app.setContext(p.context as SessionContext, typeof p?.instance === "string" ? p.instance : undefined)
|
||||
// The blocks, expanded here as this terminal's keyboard would; `text` from a hub of
|
||||
// an earlier build.
|
||||
const blocks = Array.isArray(p?.prompt) ? p.prompt : [{ type: "text", text: String(p?.text ?? "") }]
|
||||
@@ -495,7 +501,19 @@ export class Share {
|
||||
// The terminal's turns: `_lembas/session/status` for a shared session asks here.
|
||||
peer.handle("hub/status", (p) => {
|
||||
this.mine(p)
|
||||
return { ...(this.turns.running ? { turnId: this.turns.running } : {}), queued: this.turns.queued(), ...(this.turns.last ? { lastTurnId: this.turns.last } : {}) }
|
||||
return { ...(this.turns.running ? { turnId: this.turns.running } : {}), queued: this.turns.queued(), ...(this.turns.last ? { lastTurnId: this.turns.last } : {}), plan: this.app.engine.plan ?? null }
|
||||
})
|
||||
// The web UI is editing or regenerating from a turn: that turn and the later ones go, as /undo
|
||||
// drops one — the files stay.
|
||||
peer.handle("hub/rewind", (p) => {
|
||||
this.mine(p)
|
||||
if (this.app.turns.current) throw new RpcError(INVALID_PARAMS, "this session is working — rewind it when the reply is done")
|
||||
const turnId = String(p?.turnId ?? "")
|
||||
const reverted = this.app.turns.rewind(turnId)
|
||||
if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`)
|
||||
this.hooks.rewound?.()
|
||||
this.hooks.notice(`the web UI rewound ${reverted} turn${reverted === 1 ? "" : "s"} — the files they changed stay as they are`)
|
||||
return { reverted }
|
||||
})
|
||||
peer.handle("hub/steer", (p) => {
|
||||
this.mine(p)
|
||||
|
||||
+56
-5
@@ -16,7 +16,7 @@ import { hardlineRules } from "./permission/hardline.ts"
|
||||
import { defaultEffort } from "./provider/effort.ts"
|
||||
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
||||
import { discoverContext, unload } from "./provider/discover.ts"
|
||||
import { assembleSystem, instructionFiles } from "./prompt/assemble.ts"
|
||||
import { assembleSystem, instructionFiles, knowledgeScope } from "./prompt/assemble.ts"
|
||||
import { describeRepo, repoState } from "./git/repo.ts"
|
||||
import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts"
|
||||
import type { Message } from "./provider/types.ts"
|
||||
@@ -38,6 +38,7 @@ import { Settings } from "./settings.ts"
|
||||
import { Library, type Embedder } from "./library/store.ts"
|
||||
import { instances as lembasInstances, keyFile as lembasKeyFile } from "./lembas/login.ts"
|
||||
import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts"
|
||||
import type { Plan } from "./plan/plan.ts"
|
||||
import { LIBRARY_SERVER } from "./mcp/index.ts"
|
||||
import type { McpServer } from "./config/schema.ts"
|
||||
import { embedderFor } from "./library/embed.ts"
|
||||
@@ -72,6 +73,21 @@ const UNATTENDED: Record<Mode, string | undefined> = {
|
||||
auto: undefined,
|
||||
}
|
||||
|
||||
/** What only a client knows about a session (harness/acp.md, `Context` in `session/new` and
|
||||
* `session/prompt`'s `_meta.lembas`): the pieces of the prompt an instance holds, read into the
|
||||
* prompt this device composes from the spec's texts. Each is absent where nothing was said. */
|
||||
export interface SessionContext {
|
||||
/** The chat's authored prompt, under `## Instructions from <instance>`. */
|
||||
instructions?: string
|
||||
/** The account's personalization, in place of this device's; null: the device's own. */
|
||||
personalization?: { personality: string; personality_custom: string; instructions: string } | null
|
||||
/** The knowledge bases this session may search (named to the model, and the scope sent on
|
||||
* every library call); an empty list: the account's whole library. */
|
||||
library?: { bases: { id: string; name: string }[] }
|
||||
/** Whether the model may write a prototype, and what a page may load. */
|
||||
prototypes?: { offered: boolean; sources: string }
|
||||
}
|
||||
|
||||
export interface App {
|
||||
bus: Bus
|
||||
engine: Engine
|
||||
@@ -119,6 +135,10 @@ export interface App {
|
||||
embedder?: Embedder
|
||||
/** Stop whatever runs: the model's reply. */
|
||||
cancel(): void
|
||||
/** What the client holds for this session (the link): read into every prompt from now on. */
|
||||
context: SessionContext
|
||||
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
||||
setContext(ctx: SessionContext, from?: string): void
|
||||
}
|
||||
|
||||
/** This machine's library tools, which `library: lembas` replaces with the instance's. */
|
||||
@@ -243,6 +263,9 @@ export function createApp(o: AppOptions): App {
|
||||
loaded.warnings.push((e as Error).message)
|
||||
}
|
||||
const libraryCtx = { lib: library, project: project.root, bases: loaded.config.knowledge, embedder }
|
||||
// The client's context for this session (setContext), and whose it is.
|
||||
let context: SessionContext = {}
|
||||
let contextFrom = "the instance"
|
||||
|
||||
// library: lembas — the account's library through the instance's /mcp, in place of this
|
||||
// machine's. Needs the one instance logged in to, with a token that has the library scope.
|
||||
@@ -279,7 +302,20 @@ export function createApp(o: AppOptions): App {
|
||||
sessions: store ? { store, current: () => engine.sessionId } : undefined,
|
||||
settings: { get: (k: string) => settings.get(k), list: () => settings.list(), set: (k: string, v: unknown, sc?: "session" | "global" | "project") => settings.set(k, v, sc), models: () => modelRefs() },
|
||||
})
|
||||
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string }, at?: { cwd: string; root: string }) =>
|
||||
/** Whether knowledge_search is offered (engine.offered's rule): a base with documents in scope,
|
||||
* or the instance's library standing in for this machine's. */
|
||||
const knowledgeOffered = () => Boolean(lembasLibrary) || library.bases().some((b) => b.documents > 0 && (!libraryCtx.bases || libraryCtx.bases.includes(b.name)))
|
||||
/** The library block's scope: the client's bases over the link, else this machine's `knowledge:`. */
|
||||
const libraryBlock = () => {
|
||||
if (!knowledgeOffered()) return undefined
|
||||
const names = lembasLibrary ? context.library?.bases.map((b) => b.name) : libraryCtx.bases
|
||||
return { scope: knowledgeScope(names) }
|
||||
}
|
||||
/** Prototypes: offered by the client for this session, or by `prototypes: true` for a terminal's. */
|
||||
const prototypesBlock = () => (context.prototypes?.offered || (!context.prototypes && loaded.config.prototypes === true) ? { sources: context.prototypes?.sources ?? "" } : undefined)
|
||||
/** The three personal keys this session uses: the client's for it, else the config's. */
|
||||
const personal = () => (context.personalization ? personalConfig({ enabled: true, available: true, ...context.personalization }) : loaded.config)
|
||||
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string }, at?: { cwd: string; root: string }, plan?: Plan) =>
|
||||
assembleSystem({
|
||||
modelRef: current.ref,
|
||||
family: familyOf(current),
|
||||
@@ -300,9 +336,13 @@ export function createApp(o: AppOptions): App {
|
||||
skills: frozen.skills,
|
||||
manageSkills: true,
|
||||
// Read on every prompt: /personality and /settings change it without a restart.
|
||||
...personalityOf(loaded.config),
|
||||
...personalityOf(personal()),
|
||||
mcp: mcp.instructions(),
|
||||
roster: roster(current.ref),
|
||||
plan,
|
||||
...(context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}),
|
||||
library: libraryBlock(),
|
||||
prototypes: prototypesBlock(),
|
||||
})
|
||||
// The other configured models, for the task tool's `model`, or a switch.
|
||||
const roster = (current: string) =>
|
||||
@@ -394,7 +434,7 @@ export function createApp(o: AppOptions): App {
|
||||
compaction,
|
||||
perm: childPerm,
|
||||
toolCtx: at ? { ...baseToolCtx(), root: at.root, cwd: at.cwd, projectDir: undefined } : baseToolCtx(),
|
||||
system: (mode, current) =>
|
||||
system: (mode, current, plan) =>
|
||||
system(
|
||||
mode,
|
||||
current,
|
||||
@@ -405,6 +445,7 @@ export function createApp(o: AppOptions): App {
|
||||
: spec.instructions,
|
||||
},
|
||||
at,
|
||||
plan,
|
||||
),
|
||||
})
|
||||
const onAbort = () => child.cancel()
|
||||
@@ -438,7 +479,7 @@ export function createApp(o: AppOptions): App {
|
||||
compaction,
|
||||
perm,
|
||||
toolCtx: baseToolCtx(),
|
||||
system: (m, current) => system(m, current),
|
||||
system: (m, current, plan) => system(m, current, undefined, undefined, plan),
|
||||
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
|
||||
spawn,
|
||||
fallback(current, tried) {
|
||||
@@ -634,6 +675,16 @@ export function createApp(o: AppOptions): App {
|
||||
cancel() {
|
||||
engine.cancel()
|
||||
},
|
||||
get context() {
|
||||
return context
|
||||
},
|
||||
setContext(ctx, from) {
|
||||
context = ctx
|
||||
if (from) contextFrom = from
|
||||
// The instance library answers within the chat's scope: the session's id rides on every
|
||||
// call to it while the client has said what the session may search.
|
||||
engine.o.toolCtx.libraryScope = ctx.library ? engine.sessionId : undefined
|
||||
},
|
||||
rename(title) {
|
||||
const id = engine.sessionId
|
||||
if (!id || !title.trim()) return
|
||||
|
||||
+7
-6
@@ -1,9 +1,9 @@
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import type { Decision, PermissionRequest } from "../permission/evaluate.ts"
|
||||
import type { Usage } from "../provider/types.ts"
|
||||
import type { PlanReply } from "../tool/plan_exit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/question.ts"
|
||||
import type { Todo } from "../tool/todo.ts"
|
||||
import type { Plan } from "../plan/plan.ts"
|
||||
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
||||
import type { ToolResult } from "../tool/tool.ts"
|
||||
|
||||
// Everything the engine does is announced here. The TUI, the headless renderer and the tests
|
||||
@@ -50,7 +50,8 @@ export type Event =
|
||||
| { type: "notice"; message: string; level?: "info" | "warn" }
|
||||
/** Discard the reply streamed so far in this step: it is being retried. */
|
||||
| { type: "retract" }
|
||||
| { type: "todos"; items: Todo[] }
|
||||
/** The session's plan was written, changed or cleared (plan_write, plan_update, /new). */
|
||||
| { type: "plan"; plan: Plan | undefined }
|
||||
/** The conversation was replaced by a summary of it (/compact, or automatically). */
|
||||
| { type: "compacted"; summary: string }
|
||||
/** A subagent's tool call finished (shown under the task line). */
|
||||
@@ -111,6 +112,6 @@ export interface Asker {
|
||||
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
|
||||
* id, which keys the card on another screen. */
|
||||
question?(req: QuestionRequest, callId?: string): Promise<QuestionReply>
|
||||
/** plan_exit. Absent: the plan is saved and the model told to stop. */
|
||||
plan?(req: { path: string; text: string }, callId?: string): Promise<PlanReply>
|
||||
/** plan_submit. Absent: the plan is saved and the model told to stop. */
|
||||
plan?(req: PlanRequest, callId?: string): Promise<PlanReply>
|
||||
}
|
||||
+1
-1
@@ -184,7 +184,7 @@ function merge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T {
|
||||
// instructions and a custom personality's text are the person's own words, put last in the system
|
||||
// prompt as theirs: a repository writing them would speak in the user's voice.
|
||||
// A personality preset's name is only a choice among the shipped texts, and stays a project's to make.
|
||||
const GLOBAL_ONLY = ["hardline_extra", "hardline_disable", "voice", "update", "settings_tool", "embedding", "remote", "library", "instructions", "personality_custom"] as const
|
||||
const GLOBAL_ONLY = ["hardline_extra", "hardline_disable", "voice", "update", "settings_tool", "embedding", "remote", "library", "prototypes", "instructions", "personality_custom"] as const
|
||||
|
||||
export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean } = {}): Loaded {
|
||||
const warnings: string[] = []
|
||||
|
||||
@@ -11,13 +11,13 @@ export const PERSONALITIES = [...PERSONALITY_PRESETS, "custom"] as const
|
||||
export const MODE_ALIASES: Record<string, (typeof MODES)[number]> = { unrestricted: "auto" }
|
||||
export const ACTIONS = ["allow", "ask", "deny"] as const
|
||||
|
||||
export type Dialect = (typeof DIALECTS)[number]
|
||||
export type Effort = (typeof EFFORTS)[number]
|
||||
export type Mode = (typeof MODES)[number]
|
||||
export type Action = (typeof ACTIONS)[number]
|
||||
|
||||
/** Permission keys the harness spec renamed (v1), read as the new ones in a config's `permission:`. */
|
||||
export const PERMISSION_ALIASES: Record<string, string> = { question: "ask_user", plan_exit: "plan_submit", websearch: "web_search", webfetch: "web_fetch" }
|
||||
/** Permission keys the harness spec renamed (v1; 1.1.0 folded todo into the plan tools, whose key
|
||||
* is `plan`), read as the new ones in a config's `permission:` and in the spec's own defaults. */
|
||||
export const PERMISSION_ALIASES: Record<string, string> = { question: "ask_user", plan_exit: "plan_submit", websearch: "web_search", webfetch: "web_fetch", todo: "plan" }
|
||||
|
||||
export function renamePermissionKeys(p: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
@@ -235,7 +235,7 @@ export const Config = z.strictObject({
|
||||
small_model: z.string().optional().describe("`connection/model` for small jobs — session titles, commit messages. Default: the session's model. On a server that holds one model at a time (llama-swap), another model here swaps."),
|
||||
titles: z.enum(["model", "prompt"]).optional().describe("Session titles: model (default) — named from the first prompt, then by the model after its first reply; prompt — only from the first prompt, no extra request."),
|
||||
mode: z.enum(MODES).optional().describe("The permission mode a session starts in (default manual)."),
|
||||
theme: z.string().optional().describe("Colours: lembas (default), lembas-light, moria, shire, hermes, mono, or a themes/<name>.yaml of your own."),
|
||||
theme: z.string().optional().describe("Colours: moria (default), shire, lembas, lembas-light, hermes, mono, or a themes/<name>.yaml of your own."),
|
||||
theme_background: z.enum(["theme", "terminal"]).optional().describe("theme (default): the theme paints the whole screen; terminal: the terminal's own background shows, and the theme colours only text, panels and accents."),
|
||||
skin: z.string().optional().describe("The old name of `theme`; still read when `theme` is not set."),
|
||||
busy_input: z.enum(["steer", "queue"]).optional().describe("A message sent while the agent works: steer (default) — it goes in at the next step, after the running tools finish, and the agent carries on with it; queue — it is sent as the next prompt once the task is done."),
|
||||
@@ -295,6 +295,7 @@ export const Config = z.strictObject({
|
||||
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("The effort a session starts with, when the model takes it; otherwise the model's own default."),
|
||||
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
|
||||
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
|
||||
prototypes: z.boolean().optional().describe("Global config only. Offer the model prototypes: a complete HTML page in a `prototype-chat` or `prototype-page` fence, saved as a file under the state directory (prototypes/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."),
|
||||
library: z.enum(["local", "lembas"]).optional().describe("Global config only. local (default): memory, notes, skills and knowledge on this machine. lembas: the account's on the LLeMbas instance you are logged in to, through its /mcp — the same tools its chats have (needs a login with the library scope)."),
|
||||
remote: z
|
||||
.strictObject({
|
||||
|
||||
@@ -15,7 +15,6 @@ import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "
|
||||
export class SettingError extends Error {}
|
||||
|
||||
export type Scope = "session" | "global" | "project"
|
||||
export const SCOPES: Scope[] = ["session", "global", "project"]
|
||||
export type Source = "default" | "global" | "project" | "session"
|
||||
|
||||
export interface SettingDef {
|
||||
@@ -48,7 +47,7 @@ export const SETTINGS: SettingDef[] = [
|
||||
{ key: "personality", kind: "enum", values: ["none", ...PERSONALITIES], agent: false, scopes: ALL, fallback: "none" },
|
||||
{ key: "personality_custom", kind: "string", agent: false, scopes: USER, fallback: "none" },
|
||||
{ key: "instructions", kind: "string", agent: false, scopes: USER, fallback: "none" },
|
||||
{ key: "theme", kind: "string", agent: true, scopes: USER, fallback: "lembas" },
|
||||
{ key: "theme", kind: "string", agent: true, scopes: USER, fallback: "moria" },
|
||||
{ key: "theme_background", kind: "enum", values: ["theme", "terminal"], agent: true, scopes: USER, fallback: "theme" },
|
||||
{ key: "busy_input", kind: "enum", values: ["steer", "queue"], agent: true, scopes: USER, fallback: "steer" },
|
||||
{ key: "icons", kind: "enum", values: ["emoji", "plain"], agent: true, scopes: USER, fallback: "emoji" },
|
||||
|
||||
+4
-11
@@ -5,8 +5,9 @@
|
||||
// that comes first on PATH, it is what `lembas` runs — and the person typing it gets a Python
|
||||
// traceback or a web server instead of this program. install.sh says so when it installs; this says
|
||||
// so whenever the configuration is checked.
|
||||
import { accessSync, constants, openSync, readSync, closeSync, realpathSync, statSync } from "node:fs"
|
||||
import { accessSync, constants, openSync, readSync, closeSync, statSync } from "node:fs"
|
||||
import { delimiter, dirname, join } from "node:path"
|
||||
import { realPath } from "./permission/evaluate.ts"
|
||||
|
||||
/** The first `lembas` on `path`, the way a shell finds it. */
|
||||
export function firstOnPath(name: string, path = process.env.PATH ?? ""): string | undefined {
|
||||
@@ -22,18 +23,10 @@ export function firstOnPath(name: string, path = process.env.PATH ?? ""): string
|
||||
return undefined
|
||||
}
|
||||
|
||||
function real(p: string): string {
|
||||
try {
|
||||
return realpathSync(p)
|
||||
} catch {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a file is a Python script (its first line a `#!` naming python). */
|
||||
export function isPythonScript(file: string): boolean {
|
||||
try {
|
||||
const fd = openSync(real(file), "r")
|
||||
const fd = openSync(realPath(file), "r")
|
||||
const buf = Buffer.alloc(300)
|
||||
const n = readSync(fd, buf, 0, 300, 0)
|
||||
closeSync(fd)
|
||||
@@ -51,7 +44,7 @@ export function lembasShadowed(o: { path?: string; self?: string } = {}): string
|
||||
const self = "self" in o ? o.self : /(^|\/)bun$/.test(process.execPath) ? undefined : process.execPath
|
||||
const found = firstOnPath("lembas", o.path)
|
||||
if (!found) return undefined
|
||||
if (self && real(found) === real(self)) return undefined
|
||||
if (self && realPath(found) === realPath(self)) return undefined
|
||||
const python = isPythonScript(found)
|
||||
if (!python && !self) return undefined
|
||||
const what = python ? "a Python console script — most likely the LLeMbas server's own `lembas`" : "another program"
|
||||
|
||||
@@ -20,4 +20,3 @@ export function git(cwd: string, args: string[], opts: { env?: Record<string, st
|
||||
return { code: r.exitCode ?? 1, out: r.stdout.toString().replace(/\s+$/, ""), err: r.exitCode === null && !err ? `git ${args[0]} did not finish in ${Math.round((opts.timeoutMs ?? 120_000) / 1000)} s (a key waiting for a passphrase? a slow hook?)` : err }
|
||||
}
|
||||
|
||||
export const hasGit = Bun.which("git") !== null
|
||||
+19
-5
@@ -3,6 +3,7 @@
|
||||
// them; the code validates with its own zod schema, and tests/harness.test.ts keeps the two equal.
|
||||
import VERSION from "../harness/VERSION" with { type: "text" }
|
||||
import PURPOSE from "../harness/purpose.json"
|
||||
import LOOP_JSON from "../harness/loop.json"
|
||||
import t_apply_patch from "../harness/tools/apply_patch.json"
|
||||
import t_ask_user from "../harness/tools/ask_user.json"
|
||||
import t_bash from "../harness/tools/bash.json"
|
||||
@@ -22,6 +23,8 @@ import t_note_manage from "../harness/tools/note_manage.json"
|
||||
import t_note_view from "../harness/tools/note_view.json"
|
||||
import t_notes_search from "../harness/tools/notes_search.json"
|
||||
import t_plan_submit from "../harness/tools/plan_submit.json"
|
||||
import t_plan_update from "../harness/tools/plan_update.json"
|
||||
import t_plan_write from "../harness/tools/plan_write.json"
|
||||
import t_read from "../harness/tools/read.json"
|
||||
import t_session_search from "../harness/tools/session_search.json"
|
||||
import t_settings from "../harness/tools/settings.json"
|
||||
@@ -30,7 +33,6 @@ import t_skill_view from "../harness/tools/skill_view.json"
|
||||
import t_skills_list from "../harness/tools/skills_list.json"
|
||||
import t_task from "../harness/tools/task.json"
|
||||
import t_tasks from "../harness/tools/tasks.json"
|
||||
import t_todo from "../harness/tools/todo.json"
|
||||
import t_view_image from "../harness/tools/view_image.json"
|
||||
import t_web_fetch from "../harness/tools/web_fetch.json"
|
||||
import t_web_search from "../harness/tools/web_search.json"
|
||||
@@ -44,9 +46,10 @@ export interface ToolSpecDef {
|
||||
risk: "read" | "write" | "execute" | "interact"
|
||||
exclusive: boolean
|
||||
/** How a call is shown in a transcript: a shell command, an edit with its diff, a
|
||||
* file read, a search with its hits, a web lookup, a delegated task, hidden (the todo list has a
|
||||
* panel of its own), or a plain tool line. Both projects draw the same kind the same way. */
|
||||
block: "shell" | "edit" | "read" | "search" | "web" | "task" | "hidden" | "tool"
|
||||
* file read, a search with its hits, a web lookup, a delegated task, a plan change (not drawn:
|
||||
* the plan card refreshes), hidden, or a plain tool line. Both projects draw the same kind the
|
||||
* same way. */
|
||||
block: "shell" | "edit" | "read" | "search" | "web" | "task" | "plan" | "hidden" | "tool"
|
||||
/** Where a call says what it is for: the injected `purpose`, a field of its own, or none. */
|
||||
purpose: string | false
|
||||
/** Names each project used before spec v1; a call under one is read as this tool. */
|
||||
@@ -60,6 +63,17 @@ export interface ToolSpecDef {
|
||||
|
||||
export const HARNESS_VERSION = VERSION.trim()
|
||||
|
||||
/** How one prompt runs (harness/loop.json): the step backstop, the budgets' defaults, the wrap-up
|
||||
* message, how often a reply that says nothing is asked again, how many calls run together. */
|
||||
export const LOOP: {
|
||||
steps: { default: number }
|
||||
budgets: { wall_seconds: { cli: number | null; llembas: number | null }; output_bytes: { cli: number | null; llembas: number | null }; completion_tokens: { cli: number | null; llembas: number | null } }
|
||||
/** `message` holds `{{limit}}`: which limit was reached, as a phrase. */
|
||||
wrap_up: { message: string }
|
||||
nudges: { max: number }
|
||||
parallel: { max: number }
|
||||
} = LOOP_JSON
|
||||
|
||||
/** The `purpose` argument (harness/purpose.json). */
|
||||
export const PURPOSE_DEF: { name: string; schema: Record<string, unknown> } = PURPOSE
|
||||
|
||||
@@ -73,7 +87,7 @@ export function fillDescription(description: string, vars: Record<string, string
|
||||
}
|
||||
|
||||
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
|
||||
([t_apply_patch, t_ask_user, t_bash, t_bash_kill, t_bash_list, t_bash_output, t_decisions, t_edit, t_glob, t_grep, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_read, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_todo, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
|
||||
([t_apply_patch, t_ask_user, t_bash, t_bash_kill, t_bash_list, t_bash_output, t_decisions, t_edit, t_glob, t_grep, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_read, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
|
||||
)
|
||||
|
||||
/** A tool's name and description as the spec has them, for its definition: `...described("read")`. */
|
||||
|
||||
+5
-5
@@ -2,8 +2,8 @@
|
||||
// (so the answer can be piped), or everything as JSON lines with --json.
|
||||
import { createInterface } from "node:readline/promises"
|
||||
import type { Asker, AskReply, Bus, Event } from "./bus/index.ts"
|
||||
import { unattendedReply, type Answer, type QuestionReply, type QuestionRequest } from "./tool/question.ts"
|
||||
import type { PlanReply } from "./tool/plan_exit.ts"
|
||||
import { unattendedReply, type Answer, type QuestionReply, type QuestionRequest } from "./tool/ask_user.ts"
|
||||
import type { PlanReply, PlanRequest } from "./tool/plan_submit.ts"
|
||||
import type { IconSet } from "./tui/icons.ts"
|
||||
import { loadTheme, type Theme } from "./tui/palette.ts"
|
||||
import { duration } from "./duration.ts"
|
||||
@@ -92,13 +92,13 @@ export function renderJson(bus: Bus) {
|
||||
export function terminalAsker(opts: { yes: boolean; unattended?: "recommended" | "first" | "fail"; theme?: string }): Asker {
|
||||
const C = escapes(loadTheme(opts.theme).theme)
|
||||
return {
|
||||
// With nobody at a terminal there is no plan method at all: plan_exit then saves and stops.
|
||||
// With nobody at a terminal there is no plan method at all: plan_submit then saves and stops.
|
||||
...(process.stdin.isTTY && !opts.yes
|
||||
? {
|
||||
async plan(req: { path: string; text: string }): Promise<PlanReply> {
|
||||
async plan(req: PlanRequest): Promise<PlanReply> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
||||
try {
|
||||
process.stderr.write(`\n${C.label}── plan: ${req.path} ──${C.reset}\n${req.text}\n${C.label}──${C.reset}\n`)
|
||||
process.stderr.write(`\n${C.label}── plan · ${req.plan.title}${req.path ? ` (${req.path})` : ""} ──${C.reset}\n${req.text}\n${C.label}──${C.reset}\n`)
|
||||
const a = (await rl.question(" [a]pprove (edit mode) [m]anual mode [r]evise… [?] ask back… [enter] keep planning › ")).trim().toLowerCase()
|
||||
if (a === "a") return { kind: "approve", mode: "edit" }
|
||||
if (a === "m") return { kind: "approve", mode: "manual" }
|
||||
|
||||
@@ -159,10 +159,6 @@ export function expandWebui(name: string, w: WebuiConnection, cache = readCache(
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebui(c: unknown): c is WebuiLoaded {
|
||||
return !!c && typeof c === "object" && "webui" in c && !!(c as WebuiLoaded).webui
|
||||
}
|
||||
|
||||
/** Read the instance's models and services, and keep them. Throws LembasError. */
|
||||
export async function fetchWebui(name: string, url: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<Cache> {
|
||||
const base = normalise(url)
|
||||
|
||||
@@ -92,6 +92,21 @@ export interface Note {
|
||||
updated: number
|
||||
}
|
||||
|
||||
/** knowledge_search's `base` against the bases a conversation may search (harness
|
||||
* conformance/library_scope.json, shared with LLeMbas): no `base` is all of them; an id matches
|
||||
* first, then a name whatever its case; anything else is the error the tool answers with,
|
||||
* naming what there is. */
|
||||
export function resolveBase(bases: { id: string; name: string }[], base: string | null | undefined): { ids: string[] } | { error: string } {
|
||||
const wanted = (base ?? "").trim()
|
||||
if (!wanted) return { ids: bases.map((b) => b.id) }
|
||||
const byId = bases.find((b) => b.id === wanted)
|
||||
if (byId) return { ids: [byId.id] }
|
||||
const lower = wanted.toLowerCase()
|
||||
const byName = bases.find((b) => b.name.toLowerCase() === lower)
|
||||
if (byName) return { ids: [byName.id] }
|
||||
return { error: `No knowledge base called "${wanted}" here. Available: ${bases.map((b) => b.name).join(", ") || "none"}.` }
|
||||
}
|
||||
|
||||
/** An embedding model: turns texts into vectors (unit length), all of one width. */
|
||||
export interface Embedder {
|
||||
model: string
|
||||
|
||||
+3
-1
@@ -392,7 +392,9 @@ export class McpManager {
|
||||
run: async (args, ctx) => {
|
||||
const c = await this.clientOf(s.name)
|
||||
try {
|
||||
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown> }, undefined, {
|
||||
// The instance's library answers within the chat's scope (harness/acp.md, Scope).
|
||||
const scope = library ? ctx.libraryScope : undefined
|
||||
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown>, ...(scope ? { _meta: { lembas: { scope } } } : {}) }, undefined, {
|
||||
signal: ctx.signal,
|
||||
timeout: (s.cfg.timeout ?? 120) * 1000,
|
||||
resetTimeoutOnProgress: true,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { lstatSync, readlinkSync, realpathSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { basename, dirname, join, relative, resolve } from "node:path"
|
||||
import { localRoot } from "../config/paths.ts"
|
||||
import type { Action, Mode, PermissionConfig } from "../config/schema.ts"
|
||||
import { renamePermissionKeys, type Action, type Mode, type PermissionConfig } from "../config/schema.ts"
|
||||
import { prefix } from "./arity.ts"
|
||||
import { splitCommand, words } from "./bash.ts"
|
||||
import { hardlineCommand, plainCommands, protectedPath, type HardlineRule } from "./hardline.ts"
|
||||
@@ -50,7 +50,9 @@ export interface Decision {
|
||||
}
|
||||
|
||||
// The rules every session starts from: the harness spec's (harness/permission/defaults.json).
|
||||
export const DEFAULT_RULES = DEFAULTS.rules as PermissionConfig
|
||||
// A key the spec still writes under a former name (defaults.json says `todo`) is read as the key
|
||||
// it is now, as a user's config is.
|
||||
export const DEFAULT_RULES = renamePermissionKeys(DEFAULTS.rules) as PermissionConfig
|
||||
|
||||
export function toRules(config: PermissionConfig, source?: Rule["source"]): Rule[] {
|
||||
const rules: Rule[] = []
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// The plan as a structure (harness spec 1.1.0, shared with LLeMbas: services/plans.py), kept per
|
||||
// session by the engine, written whole by plan_write, changed by plan_update, shown as a card in
|
||||
// every UI and put into the prompt on every step while it exists.
|
||||
//
|
||||
// Three rules hold it up. `steps` is always written, flattened from every phase's tasks in order.
|
||||
// `normalise` is the only reader: a loose shape a small model sends — a bare string for a task, a
|
||||
// `{title, steps}` list, one phase sent without the array — becomes the one shape everything
|
||||
// downstream knows. Ids are generated here and never chosen by the model: letting it name them
|
||||
// would mean validating names it made up, and a collision would silently re-tick a different task.
|
||||
// harness/conformance/plan.json is the contract for all three operations.
|
||||
|
||||
export const VERSION = 2
|
||||
|
||||
// Bounds. A plan is read by a person and injected into every request while the work is going on,
|
||||
// so "as many as you like" costs the window forever and buries the four items that mattered.
|
||||
export const MAX_PHASES = 8
|
||||
export const MAX_TASKS = 12
|
||||
export const MAX_OBJECTIVES = 8
|
||||
export const MAX_FINDINGS = 20
|
||||
export const MAX_TEXT = 300
|
||||
export const MAX_TITLE = 120
|
||||
|
||||
export const TASK_STATUSES = ["todo", "doing", "done", "dropped"] as const
|
||||
export const OBJECTIVE_STATUSES = ["open", "done", "dropped"] as const
|
||||
export const PHASE_STATUSES = ["pending", "active", "done"] as const
|
||||
|
||||
export type TaskStatus = (typeof TASK_STATUSES)[number]
|
||||
export type ObjectiveStatus = (typeof OBJECTIVE_STATUSES)[number]
|
||||
export type PhaseStatus = (typeof PHASE_STATUSES)[number]
|
||||
|
||||
export interface Finding {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
export interface Objective {
|
||||
id: string
|
||||
text: string
|
||||
status: ObjectiveStatus
|
||||
}
|
||||
export interface Task {
|
||||
id: string
|
||||
text: string
|
||||
status: TaskStatus
|
||||
/** Why it was dropped, or what came of it. */
|
||||
note: string
|
||||
}
|
||||
export interface Phase {
|
||||
id: string
|
||||
title: string
|
||||
status: PhaseStatus
|
||||
tasks: Task[]
|
||||
}
|
||||
export interface Plan {
|
||||
version: number
|
||||
title: string
|
||||
summary: string
|
||||
findings: Finding[]
|
||||
objectives: Objective[]
|
||||
phases: Phase[]
|
||||
/** Every task's text in order — what a reader that knows nothing of phases gets. */
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
/** The former todo tool's statuses, as the plan's (the spec's one argument conversion). */
|
||||
export const TODO_STATUSES: Record<string, TaskStatus> = { pending: "todo", in_progress: "doing", completed: "done", cancelled: "dropped" }
|
||||
|
||||
type Loose = Record<string, unknown>
|
||||
|
||||
/** Whitespace collapsed, cut at `limit`; nothing for nothing (Python's `str(value or "")`). */
|
||||
function text(value: unknown, limit = MAX_TEXT): string {
|
||||
return (value ? String(value) : "").split(/\s+/).filter(Boolean).join(" ").slice(0, limit)
|
||||
}
|
||||
|
||||
function status<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
const wanted = (value ? String(value) : "").trim().toLowerCase()
|
||||
return (allowed as readonly string[]).includes(wanted) ? (wanted as T) : fallback
|
||||
}
|
||||
|
||||
/** A list, from a list or from the one thing a model sent instead: a small model sends something
|
||||
* close to the schema rather than the schema, and refusing costs a round trip to say so. */
|
||||
function listed(value: unknown): unknown[] {
|
||||
if (value === null || value === undefined) return []
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
const isObject = (v: unknown): v is Loose => !!v && typeof v === "object" && !Array.isArray(v)
|
||||
|
||||
// --- Reading -----------------------------------------------------------------------------------
|
||||
|
||||
/** What plan_write stores for what a model sent. `{}` for nothing. A `{title, steps}` list becomes
|
||||
* one phase called "Plan" whose tasks are the steps. */
|
||||
export function normalise(raw: unknown): Plan | Record<string, never> {
|
||||
if (!isObject(raw) || Object.keys(raw).length === 0) return {}
|
||||
|
||||
const title = text(raw.title, MAX_TITLE) || "A plan"
|
||||
const findings: Finding[] = listed(raw.findings)
|
||||
.slice(0, MAX_FINDINGS)
|
||||
.map((item, i) => ({ id: `f${i + 1}`, text: text(isObject(item) ? item.text : item) }))
|
||||
.filter((f) => f.text)
|
||||
|
||||
const objectives: Objective[] = []
|
||||
listed(raw.objectives)
|
||||
.slice(0, MAX_OBJECTIVES)
|
||||
.forEach((item, i) => {
|
||||
const source: Loose = isObject(item) ? item : { text: item }
|
||||
const t = text(source.text)
|
||||
if (t) objectives.push({ id: `o${i + 1}`, text: t, status: status(source.status, OBJECTIVE_STATUSES, "open") })
|
||||
})
|
||||
|
||||
let phases = readPhases(raw)
|
||||
if (!phases.length) {
|
||||
// Version 1, or a model that sent only steps: one phase, so nothing else sees the older shape.
|
||||
const tasks = listed(raw.steps)
|
||||
.map((s) => text(s))
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_TASKS)
|
||||
phases = [{ id: "p1", title: "Plan", status: "pending", tasks: tasks.map((t, i) => ({ id: `t${i + 1}`, text: t, status: "todo", note: "" })) }]
|
||||
}
|
||||
|
||||
const plan: Plan = { version: VERSION, title, summary: text(raw.summary), findings, objectives, phases, steps: [] }
|
||||
plan.steps = flatten(plan)
|
||||
return plan
|
||||
}
|
||||
|
||||
function readPhases(raw: Loose): Phase[] {
|
||||
const out: Phase[] = []
|
||||
let counter = 0
|
||||
listed(raw.phases)
|
||||
.slice(0, MAX_PHASES)
|
||||
.forEach((item, i) => {
|
||||
const source: Loose = isObject(item) ? item : { title: item }
|
||||
const tasks: Task[] = []
|
||||
for (const entry of listed(source.tasks).slice(0, MAX_TASKS)) {
|
||||
const got: Loose = isObject(entry) ? entry : { text: entry }
|
||||
const t = text(got.text)
|
||||
if (!t) continue
|
||||
counter++
|
||||
tasks.push({ id: `t${counter}`, text: t, status: status(got.status, TASK_STATUSES, "todo"), note: text(got.note) })
|
||||
}
|
||||
const title = text(source.title, MAX_TITLE)
|
||||
if (!title && !tasks.length) return
|
||||
out.push({ id: `p${i + 1}`, title: title || `Phase ${i + 1}`, status: status(source.status, PHASE_STATUSES, "pending"), tasks })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether normalise gave a plan (it gives `{}` for nothing). */
|
||||
export const isPlan = (p: Plan | Record<string, never>): p is Plan => "version" in p
|
||||
|
||||
/** Every task, in order, as plain sentences. */
|
||||
export function flatten(plan: { phases?: Phase[] }): string[] {
|
||||
return (plan.phases ?? []).flatMap((p) => p.tasks.map((t) => t.text))
|
||||
}
|
||||
|
||||
/** The former todo tool's list as a plan: one phase called "Work", active while anything is
|
||||
* being done; a bare string is a pending item. `{}` for an empty list. */
|
||||
export function fromTodos(items: unknown): Plan | Record<string, never> {
|
||||
const tasks: Task[] = []
|
||||
listed(items)
|
||||
.slice(0, MAX_TASKS)
|
||||
.forEach((item, i) => {
|
||||
const source: Loose = isObject(item) ? item : { content: item }
|
||||
const t = text("content" in source ? source.content : source.text)
|
||||
if (!t) return
|
||||
const s = (source.status ? String(source.status) : "pending").trim().toLowerCase()
|
||||
tasks.push({ id: `t${i + 1}`, text: t, status: TODO_STATUSES[s] ?? "todo", note: "" })
|
||||
})
|
||||
if (!tasks.length) return {}
|
||||
const plan: Plan = {
|
||||
version: VERSION,
|
||||
title: "Work",
|
||||
summary: "",
|
||||
findings: [],
|
||||
objectives: [],
|
||||
phases: [{ id: "p1", title: "Work", status: tasks.some((t) => t.status === "doing") ? "active" : "pending", tasks }],
|
||||
steps: [],
|
||||
}
|
||||
plan.steps = flatten(plan)
|
||||
return plan
|
||||
}
|
||||
|
||||
// --- Changing ----------------------------------------------------------------------------------
|
||||
|
||||
export interface PlanChanges {
|
||||
tasks?: { id: string; status?: string; note?: string }[]
|
||||
objectives?: { id: string; status?: string }[]
|
||||
phases?: { id: string; status?: string }[]
|
||||
add_tasks?: { phase: string; text: string; after?: string }[]
|
||||
add_findings?: string[]
|
||||
}
|
||||
|
||||
const ids = (list: { id: string }[]) => list.map((x) => x.id)
|
||||
const unknown = (what: string, id: string, known: string[]) => `Unknown ${what} id "${id}". Known: ${known.join(", ") || "none"}.`
|
||||
const badStatus = (value: unknown, what: string, allowed: readonly string[]) => `Unknown status "${value}" for ${what}. One of: ${allowed.join(", ")}.`
|
||||
const number = (id: string) => Number(id.replace(/^\D+/, "")) || 0
|
||||
|
||||
/** plan_update's changes applied to a stored plan. Every id and status is checked first; on an
|
||||
* error nothing changes, and the error names the known ids (the model quotes them from the plan
|
||||
* it was shown). The plan given is not touched: the result is a new one. An addition past a
|
||||
* bound (MAX_TASKS in a phase, MAX_FINDINGS) is left out, as LLeMbas leaves it out. */
|
||||
export function apply(stored: Plan, changes: unknown): { plan: Plan } | { error: string } {
|
||||
const plan: Plan = structuredClone(stored)
|
||||
const c: PlanChanges = isObject(changes) ? (changes as PlanChanges) : {}
|
||||
const tasks = plan.phases.flatMap((p) => p.tasks)
|
||||
const phaseOf = new Map(plan.phases.flatMap((p) => p.tasks.map((t) => [t.id, p] as const)))
|
||||
const taskIds = ids(tasks)
|
||||
const phaseIds = ids(plan.phases)
|
||||
const objectiveIds = ids(plan.objectives)
|
||||
const id = (v: unknown) => (v ? String(v) : "").trim()
|
||||
const lower = (v: unknown) => String(v).trim().toLowerCase()
|
||||
const has = (allowed: readonly string[], v: unknown) => allowed.includes(lower(v))
|
||||
|
||||
const taskChanges = listed(c.tasks).filter(isObject) as NonNullable<PlanChanges["tasks"]>
|
||||
const phaseChanges = listed(c.phases).filter(isObject) as NonNullable<PlanChanges["phases"]>
|
||||
const objectiveChanges = listed(c.objectives).filter(isObject) as NonNullable<PlanChanges["objectives"]>
|
||||
const added = listed(c.add_tasks).filter(isObject) as NonNullable<PlanChanges["add_tasks"]>
|
||||
const findings = listed(c.add_findings).map((f) => text(isObject(f) ? f.text : f))
|
||||
|
||||
// Everything is checked before anything is touched.
|
||||
for (const t of taskChanges) {
|
||||
if (!taskIds.includes(id(t.id))) return { error: unknown("task", id(t.id), taskIds) }
|
||||
if (t.status !== undefined && t.status !== null && !has(TASK_STATUSES, t.status)) return { error: badStatus(t.status, "a task", TASK_STATUSES) }
|
||||
}
|
||||
for (const p of phaseChanges) {
|
||||
if (!phaseIds.includes(id(p.id))) return { error: unknown("phase", id(p.id), phaseIds) }
|
||||
if (p.status !== undefined && p.status !== null && !has(PHASE_STATUSES, p.status)) return { error: badStatus(p.status, "a phase", PHASE_STATUSES) }
|
||||
}
|
||||
for (const o of objectiveChanges) {
|
||||
if (!objectiveIds.includes(id(o.id))) return { error: unknown("objective", id(o.id), objectiveIds) }
|
||||
if (o.status !== undefined && o.status !== null && !has(OBJECTIVE_STATUSES, o.status)) return { error: badStatus(o.status, "an objective", OBJECTIVE_STATUSES) }
|
||||
}
|
||||
for (const a of added) {
|
||||
if (!phaseIds.includes(id(a.phase))) return { error: unknown("phase", id(a.phase), phaseIds) }
|
||||
if (id(a.after) && !taskIds.includes(id(a.after))) return { error: unknown("task", id(a.after), taskIds) }
|
||||
if (!text(a.text)) return { error: "A new task needs its text." }
|
||||
}
|
||||
|
||||
for (const t of taskChanges) {
|
||||
const task = tasks.find((x) => x.id === id(t.id))!
|
||||
if (t.status !== undefined && t.status !== null) task.status = lower(t.status) as TaskStatus
|
||||
if (t.note !== undefined && t.note !== null) task.note = text(t.note)
|
||||
}
|
||||
for (const o of objectiveChanges) if (o.status !== undefined && o.status !== null) plan.objectives.find((x) => x.id === id(o.id))!.status = lower(o.status) as ObjectiveStatus
|
||||
for (const p of phaseChanges) if (p.status !== undefined && p.status !== null) plan.phases.find((x) => x.id === id(p.id))!.status = lower(p.status) as PhaseStatus
|
||||
|
||||
// New ids continue after the highest existing one, in the order given; a task goes after the
|
||||
// one named when that is in the phase, else at the phase's end.
|
||||
let next = Math.max(0, ...taskIds.map(number))
|
||||
for (const a of added) {
|
||||
const phase = plan.phases.find((x) => x.id === id(a.phase))!
|
||||
if (phase.tasks.length >= MAX_TASKS) continue
|
||||
const task: Task = { id: `t${++next}`, text: text(a.text), status: "todo", note: "" }
|
||||
const after = id(a.after)
|
||||
const at = after && phaseOf.get(after)?.id === phase.id ? phase.tasks.findIndex((x) => x.id === after) : -1
|
||||
if (at >= 0) phase.tasks.splice(at + 1, 0, task)
|
||||
else phase.tasks.push(task)
|
||||
}
|
||||
let nextFinding = Math.max(0, ...plan.findings.map((f) => number(f.id)))
|
||||
for (const f of findings) {
|
||||
if (!f || plan.findings.length >= MAX_FINDINGS) continue
|
||||
plan.findings.push({ id: `f${++nextFinding}`, text: f })
|
||||
}
|
||||
|
||||
plan.steps = flatten(plan)
|
||||
return { plan }
|
||||
}
|
||||
|
||||
// --- Showing -----------------------------------------------------------------------------------
|
||||
|
||||
export const TASK_MARK: Record<TaskStatus, string> = { todo: "[ ]", doing: "[>]", done: "[x]", dropped: "[~]" }
|
||||
|
||||
/** The plan as text with its ids shown — what the model reads back from plan_write, what goes
|
||||
* into the prompt, and what the plan card draws. */
|
||||
export function render(plan: Plan): string {
|
||||
const lines = [`# ${plan.title}`]
|
||||
if (plan.summary) lines.push(plan.summary)
|
||||
if (plan.findings.length) lines.push(`Findings: ${plan.findings.map((f) => `[${f.id}] ${f.text}`).join("; ")}`)
|
||||
if (plan.objectives.length) lines.push(`Objectives: ${plan.objectives.map((o) => `[${o.id}] ${o.text} (${o.status})`).join("; ")}`)
|
||||
for (const p of plan.phases) {
|
||||
lines.push(`## ${p.id} ${p.title} (${p.status})`)
|
||||
for (const t of p.tasks) lines.push(`[${t.id}] ${TASK_MARK[t.status]} ${t.text}${t.note ? ` — ${t.note}` : ""}`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/** Done against what is still to do: dropped tasks count for neither. */
|
||||
export function progress(plan: Plan): { done: number; total: number } {
|
||||
const tasks = plan.phases.flatMap((p) => p.tasks)
|
||||
return { done: tasks.filter((t) => t.status === "done").length, total: tasks.filter((t) => t.status !== "dropped").length }
|
||||
}
|
||||
|
||||
/** Whether anything on the plan is still to be done — the panel shows while this holds. */
|
||||
export function open(plan: Plan): boolean {
|
||||
return plan.phases.some((p) => p.tasks.some((t) => t.status === "todo" || t.status === "doing"))
|
||||
}
|
||||
|
||||
export interface SummaryLine {
|
||||
status: TaskStatus
|
||||
text: string
|
||||
}
|
||||
|
||||
/** The digest for the panel above the prompt, at most six lines: the active phase and the count
|
||||
* on the first, then the task being done, what is next, and the last two done. */
|
||||
export function summary(plan: Plan): { head: string; lines: SummaryLine[] } {
|
||||
const { done, total } = progress(plan)
|
||||
const phase = plan.phases.find((p) => p.status === "active") ?? plan.phases.find((p) => p.tasks.some((t) => t.status === "doing" || t.status === "todo"))
|
||||
const head = `plan ${done}/${total}${phase && plan.phases.length > 1 ? ` · ${phase.title}` : ` · ${plan.title}`}`
|
||||
const tasks = plan.phases.flatMap((p) => p.tasks)
|
||||
const order = [...tasks.filter((t) => t.status === "doing"), ...tasks.filter((t) => t.status === "todo"), ...tasks.filter((t) => t.status === "done").slice(-2)]
|
||||
return { head, lines: order.slice(0, 5).map((t) => ({ status: t.status, text: t.text })) }
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export interface AgentSpec {
|
||||
}
|
||||
|
||||
/** Never given to a subagent: it cannot spawn more, and it has no user of its own to ask. */
|
||||
export const NOT_FOR_SUBAGENTS = new Set(["task", "ask_user", "plan_submit", "todo", "tasks", "decisions", "memory", "skill_manage", "settings", "note_manage"])
|
||||
export const NOT_FOR_SUBAGENTS = new Set(["task", "ask_user", "plan_submit", "plan_write", "plan_update", "tasks", "decisions", "memory", "skill_manage", "settings", "note_manage"])
|
||||
|
||||
export const BUILTIN_AGENTS: AgentSpec[] = [
|
||||
{
|
||||
|
||||
@@ -83,8 +83,3 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Absolute paths for attachments, for frecency. */
|
||||
export function attachedPaths(atts: Attachment[], root: string): string[] {
|
||||
return atts.map((a) => a.path.replace(/\/$/, "")).map((p) => (p.startsWith("/") ? p : join(root, p)))
|
||||
}
|
||||
+73
-9
@@ -3,6 +3,8 @@ import { platform, release } from "node:os"
|
||||
import { dirname, join, resolve, sep } from "node:path"
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import { expandHome, paths } from "../config/paths.ts"
|
||||
import { render, type Plan } from "../plan/plan.ts"
|
||||
import { fillPrompt } from "./fill.ts"
|
||||
import defaultSystem from "../../harness/prompts/system/default.md" with { type: "text" }
|
||||
import identity from "../../harness/prompts/system/identity.md" with { type: "text" }
|
||||
import blockMemory from "../../harness/prompts/blocks/memory.md" with { type: "text" }
|
||||
@@ -11,6 +13,8 @@ import blockSkillsEmpty from "../../harness/prompts/blocks/skills-empty.md" with
|
||||
import blockAgentsMissing from "../../harness/prompts/blocks/agents-missing.md" with { type: "text" }
|
||||
import blockAgentsGit from "../../harness/prompts/blocks/agents-git.md" with { type: "text" }
|
||||
import blockAgentsKeep from "../../harness/prompts/blocks/agents-keep.md" with { type: "text" }
|
||||
import blockLibrary from "../../harness/prompts/blocks/library.md" with { type: "text" }
|
||||
import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" }
|
||||
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
|
||||
import modeEdit from "../../harness/prompts/modes/edit.md" with { type: "text" }
|
||||
import modeAuto from "../../harness/prompts/modes/auto.md" with { type: "text" }
|
||||
@@ -43,6 +47,8 @@ const BUILTIN: Record<string, string> = {
|
||||
"blocks/agents-missing.md": blockAgentsMissing,
|
||||
"blocks/agents-git.md": blockAgentsGit,
|
||||
"blocks/agents-keep.md": blockAgentsKeep,
|
||||
"blocks/library.md": blockLibrary,
|
||||
"blocks/prototypes.md": blockPrototypes,
|
||||
"modes/manual.md": modeManual,
|
||||
"modes/edit.md": modeEdit,
|
||||
"modes/auto.md": modeAuto,
|
||||
@@ -75,10 +81,41 @@ export function promptText(rel: string): string {
|
||||
return raw.replace(/<!--[\s\S]*?-->\n?/g, "").trim()
|
||||
}
|
||||
|
||||
/** A plain replace, for the CLI's own task texts (compact, review) whose variables are whole
|
||||
* sections. A spec text with variables goes through fillPrompt (fill.ts), which also drops a
|
||||
* bullet whose variable is empty and refuses one left unfilled. */
|
||||
export function fill(text: string, vars: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (Object.hasOwn(vars, k) ? vars[k]! : m))
|
||||
}
|
||||
|
||||
// What the CLI fills into system/default.md (harness/prompts/index.json says what each means):
|
||||
// the facts the two projects differ in, not the design.
|
||||
const INTRO = "Use the instructions below and the tools you have to help with software engineering and with running the project."
|
||||
const SURFACE = "Your output is shown in a terminal and rendered as GitHub-flavoured markdown in a monospace font."
|
||||
const FILE_TOOLS = "Use the dedicated tools for files: read (not cat), edit and write (not sed or heredocs), grep and glob (not find or grep in bash)."
|
||||
const BACKGROUND = "A long-running command (a server, a watcher) runs with bash `background: true`; check it with bash_output and stop it with bash_kill."
|
||||
const GIT_SECTION = [
|
||||
"# Git",
|
||||
"- How git is used in this project — whether you commit on your own, and on which branch — is in the \"## Git\" section of its AGENTS.md. Follow it. Where nothing says, only commit, amend, push, tag or create branches when the user asks.",
|
||||
"- Before a commit, look at `git status`, `git diff` and recent `git log`, stage files by name, and write a message in the repository's style.",
|
||||
"- Never change git config, skip hooks, force-push or rewrite history unless the user explicitly asks.",
|
||||
].join("\n")
|
||||
/** Where a prototype is seen from a terminal (blocks/prototypes.md's {{prototype_surface}}). */
|
||||
export const PROTOTYPE_SURFACE = "It is saved as a file you can open, and the LLeMbas web UI renders it live."
|
||||
|
||||
/** `{{plan_context}}`: the plan with its ids under a heading, and the sentence to work to it. */
|
||||
export function planContext(plan: Plan | undefined): string {
|
||||
if (!plan) return ""
|
||||
return `### The current plan\n\n${render(plan)}\n\nThis is the plan as it stands now. Work to it, and quote the ids above rather than restating the plan in your answer.`
|
||||
}
|
||||
|
||||
/** `{{knowledge_scope}}` (blocks/library.md): the sentence naming the bases a session may search,
|
||||
* or "" where it may search them all. */
|
||||
export function knowledgeScope(names: string[] | undefined): string {
|
||||
if (!names || !names.length) return ""
|
||||
return `Knowledge searches here cover only: ${names.join(", ")}. Finding nothing there means nothing is there, not that the library is empty.`
|
||||
}
|
||||
|
||||
/** Instruction files: the global AGENTS.md, then the nearest project AGENTS.md or CLAUDE.md
|
||||
* walking up to the project root (first match wins, as OpenCode does — ancestors do not stack). */
|
||||
/** An `instructions` entry from config.yaml. A project's may only name files inside the project:
|
||||
@@ -162,21 +199,44 @@ export interface PromptContext {
|
||||
mcp?: string
|
||||
/** The other configured models, one line each (with their notes). */
|
||||
roster?: string
|
||||
/** The session's plan (plan_write / plan_update), put into the prompt while it exists. */
|
||||
plan?: Plan
|
||||
/** An instance's own instructions for this session (the chat's authored prompt, over the link),
|
||||
* appended after the shared text under `## Instructions from <name>`. */
|
||||
instance?: { name: string; instructions: string }
|
||||
/** The library block (blocks/library.md): set when knowledge tools are offered or the instance
|
||||
* library is in use; `scope` the sentence naming the bases in scope, or "" for all of them. */
|
||||
library?: { scope: string }
|
||||
/** Prototypes are offered (blocks/prototypes.md); `sources` the sentence saying what a page
|
||||
* may load, or "" where nothing says. */
|
||||
prototypes?: { sources: string }
|
||||
}
|
||||
|
||||
const MAX_INSTRUCTION_CHARS = 40_000
|
||||
|
||||
export function assembleSystem(ctx: PromptContext): string {
|
||||
const vars = { agent_name: ctx.agentName ?? "LLeMbas", plan_dir: ctx.planDir }
|
||||
const vars = {
|
||||
agent_name: ctx.agentName ?? "LLeMbas",
|
||||
plan_dir: ctx.planDir,
|
||||
intro: INTRO,
|
||||
surface: SURFACE,
|
||||
tool_list: "",
|
||||
file_tools: FILE_TOOLS,
|
||||
background: BACKGROUND,
|
||||
git: GIT_SECTION,
|
||||
plan_context: planContext(ctx.plan),
|
||||
}
|
||||
const sections: string[] = []
|
||||
// Who: the built-in line.
|
||||
sections.push(fill(promptText("system/identity.md"), vars))
|
||||
sections.push(fillPrompt(promptText("system/identity.md"), vars))
|
||||
// One shared base, then what this model family needs on top (see the wiki: Prompts-provenance).
|
||||
// A user may still replace the whole base for a family with prompts/system/<family>.md.
|
||||
const own = `system/${ctx.family}.md`
|
||||
sections.push(fill(promptText(existsSync(join(paths.config, "prompts", own)) ? own : "system/default.md"), vars))
|
||||
sections.push(fillPrompt(promptText(existsSync(join(paths.config, "prompts", own)) ? own : "system/default.md"), vars).trim())
|
||||
const overlay = `family/${ctx.family}.md`
|
||||
if (BUILTIN[overlay] || existsSync(join(paths.config, "prompts", overlay))) sections.push(fill(promptText(overlay), vars))
|
||||
if (BUILTIN[overlay] || existsSync(join(paths.config, "prompts", overlay))) sections.push(fillPrompt(promptText(overlay), vars))
|
||||
// What the instance holds for this session (over the link): the chat's authored prompt.
|
||||
if (ctx.instance?.instructions.trim()) sections.push(`## Instructions from ${ctx.instance.name}\n\n${ctx.instance.instructions.trim()}`)
|
||||
|
||||
sections.push(
|
||||
[
|
||||
@@ -214,7 +274,7 @@ export function assembleSystem(ctx: PromptContext): string {
|
||||
}
|
||||
if (ctx.skills)
|
||||
sections.push(
|
||||
fill(promptText("blocks/skills.md"), {
|
||||
fillPrompt(promptText("blocks/skills.md"), {
|
||||
skills: ctx.skills,
|
||||
manage:
|
||||
ctx.manageSkills && !ctx.subagent
|
||||
@@ -223,13 +283,17 @@ export function assembleSystem(ctx: PromptContext): string {
|
||||
}).replace(/\n{3,}/g, "\n\n"),
|
||||
)
|
||||
else if (ctx.manageSkills && !ctx.subagent) sections.push(promptText("blocks/skills-empty.md"))
|
||||
// The library and prototypes (shared blocks): the library whenever its tools are offered, named
|
||||
// to the bases in scope; prototypes only where they are offered, with where the page is seen.
|
||||
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
|
||||
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
|
||||
|
||||
if (ctx.subagent)
|
||||
sections.push(
|
||||
`You are the "${ctx.subagent.name}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask anyone anything. ${ctx.subagent.instructions}\n\nOnly your final reply reaches the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.${ctx.mode === "plan" ? " You can read and search; you cannot change anything." : ""}`,
|
||||
)
|
||||
else sections.push(fill(promptText(`modes/${ctx.mode}.md`), vars))
|
||||
if (ctx.unattended) sections.push(fill(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
|
||||
else sections.push(fillPrompt(promptText(`modes/${ctx.mode}.md`), vars))
|
||||
if (ctx.unattended) sections.push(fillPrompt(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
|
||||
// What the person said about themselves goes last, under the web UI's headings and in its order.
|
||||
// Not for a subagent: it answers the main agent, not the person.
|
||||
if (ctx.userInstructions && !ctx.subagent) sections.push(`## How the person you are talking to wants to be helped\n\n${ctx.userInstructions}`)
|
||||
@@ -246,7 +310,7 @@ const GIT_QUESTION_NO_REPO = "this folder is not a git repository yet — should
|
||||
function agentsBlock(ctx: PromptContext, files: { path: string; text: string }[]): string {
|
||||
const own = files.find((f) => f.path !== join(paths.config, "AGENTS.md"))
|
||||
const vars = { root: ctx.root, git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO }
|
||||
if (!own) return fill(promptText("blocks/agents-missing.md"), vars)
|
||||
if (!own) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
|
||||
const keep = promptText("blocks/agents-keep.md")
|
||||
return /^#{1,3}\s*git\b/im.test(own.text) ? keep : `${fill(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
|
||||
return /^#{1,3}\s*git\b/im.test(own.text) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// A prompt text's `{{variables}}` filled in (harness spec, prompts/index.json): a fact the two
|
||||
// projects differ in — the opening line, where the output is shown, the plan — written into the
|
||||
// shared text before it is sent. Two rules beyond a plain replace: a line that is nothing but a
|
||||
// bullet and a variable (`- {{name}}`) disappears when the variable is empty, so one text serves a
|
||||
// project that has the fact and one that does not; and a variable with no value is an error, since
|
||||
// it would reach the model as a literal `{{name}}`.
|
||||
|
||||
const BULLET_ONLY = /^[ \t]*[-*][ \t]+\{\{(\w+)\}\}[ \t]*$/
|
||||
|
||||
export function fillPrompt(text: string, vars: Record<string, string>): string {
|
||||
const missing = new Set<string>()
|
||||
const value = (k: string): string | undefined => {
|
||||
if (!Object.hasOwn(vars, k)) {
|
||||
missing.add(k)
|
||||
return undefined
|
||||
}
|
||||
return vars[k]!
|
||||
}
|
||||
const lines = text.split("\n")
|
||||
const out: string[] = []
|
||||
for (const line of lines) {
|
||||
const bullet = BULLET_ONLY.exec(line)
|
||||
if (bullet) {
|
||||
const v = value(bullet[1]!)
|
||||
if (v === undefined || v.trim() === "") continue
|
||||
}
|
||||
out.push(line.replace(/\{\{(\w+)\}\}/g, (m, k: string) => value(k) ?? m))
|
||||
}
|
||||
if (missing.size) throw new Error(`prompt: no value for {{${[...missing].join("}}, {{")}}}`)
|
||||
return out.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// A prototype (harness/prompts/blocks/prototypes.md, shared with LLeMbas: services/prototypes.py):
|
||||
// a complete HTML page the model writes in a fenced block whose language is `prototype-chat` or
|
||||
// `prototype-page`, with a short title on the same line. The web UI renders it live; a terminal
|
||||
// saves it as a file (prototype/store.ts) and draws one line in its place. How the fence's info
|
||||
// string is read, and where the title comes from, is pinned by harness/conformance/prototype.json.
|
||||
|
||||
export type PrototypeKind = "chat" | "page"
|
||||
|
||||
export interface PrototypeInfo {
|
||||
kind: PrototypeKind
|
||||
/** The title given on the fence line, "" when none was. */
|
||||
title: string
|
||||
}
|
||||
|
||||
/** The languages that make a fence a prototype; bare `prototype` is the inline kind. */
|
||||
const KINDS: Record<string, PrototypeKind> = { "prototype-chat": "chat", "prototype-page": "page", prototype: "chat" }
|
||||
|
||||
export const TITLE_MAX = 80
|
||||
|
||||
const collapse = (s: string) => s.split(/\s+/).filter(Boolean).join(" ")
|
||||
const cut = (s: string) => collapse(s).slice(0, TITLE_MAX).trim()
|
||||
|
||||
/** A fence's info string as a prototype, or null for any other code block. The first word
|
||||
* (whatever its case) is the language; the rest of the line is the title. */
|
||||
export function parseInfo(info: string): PrototypeInfo | null {
|
||||
const m = /^\s*(\S+)(?:\s+([\s\S]*))?$/.exec(info)
|
||||
if (!m) return null
|
||||
const kind = KINDS[m[1]!.toLowerCase()]
|
||||
if (!kind) return null
|
||||
return { kind, title: cut(m[2] ?? "") }
|
||||
}
|
||||
|
||||
/** The prototype's title: the fence's, else the page's own <title> with its tags stripped,
|
||||
* whitespace collapsed, at most TITLE_MAX characters; "" when neither says. */
|
||||
export function titleFor(code: string, given: string): string {
|
||||
const own = cut(given)
|
||||
if (own) return own
|
||||
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(code)
|
||||
return m ? cut(m[1]!.replace(/<[^>]*>/g, "")) : ""
|
||||
}
|
||||
|
||||
/** A piece of a reply: markdown as it is, or a prototype fence — `closed` once its closing fence
|
||||
* has arrived (a fence still streaming is drawn as the code block it is so far). `start` is where
|
||||
* the fence begins in the text, which stays put as the reply grows: a key for what was saved. */
|
||||
export type Segment = { kind: "text"; text: string } | { kind: "prototype"; start: number; info: PrototypeInfo; code: string; raw: string; closed: boolean }
|
||||
|
||||
const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
|
||||
const CLOSE = (fence: string) => new RegExp(`^ {0,3}${fence[0] === "`" ? "`" : "~"}{${fence.length},}\\s*$`)
|
||||
|
||||
/** The reply split into markdown and prototype fences. A fence of any other language is markdown.
|
||||
* Inside a fence nothing opens another (a fence of the other character or a longer one is
|
||||
* content), as CommonMark reads them. */
|
||||
export function splitPrototypes(text: string): Segment[] {
|
||||
const out: Segment[] = []
|
||||
const lines = text.split("\n")
|
||||
let plain: string[] = []
|
||||
let offset = 0
|
||||
const flush = () => {
|
||||
if (plain.length) out.push({ kind: "text", text: plain.join("\n") })
|
||||
plain = []
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!
|
||||
const open = OPEN.exec(line)
|
||||
if (!open) {
|
||||
plain.push(line)
|
||||
offset += line.length + 1
|
||||
continue
|
||||
}
|
||||
const fence = open[1]!
|
||||
const info = open[2] ?? ""
|
||||
const close = CLOSE(fence)
|
||||
let j = i + 1
|
||||
while (j < lines.length && !close.test(lines[j]!)) j++
|
||||
const closed = j < lines.length
|
||||
const proto = parseInfo(info)
|
||||
const body = lines.slice(i + 1, j)
|
||||
if (!proto) {
|
||||
// Some other code block: text, up to its end (or the end of the reply while it streams).
|
||||
const until = closed ? j : lines.length - 1
|
||||
for (let k = i; k <= until; k++) {
|
||||
plain.push(lines[k]!)
|
||||
offset += lines[k]!.length + 1
|
||||
}
|
||||
i = until
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
const raw = lines.slice(i, closed ? j + 1 : lines.length).join("\n")
|
||||
out.push({ kind: "prototype", start: offset, info: proto, code: body.join("\n"), raw, closed })
|
||||
offset += raw.length + 1
|
||||
i = closed ? j : lines.length
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Where a terminal keeps the prototypes a session's model wrote: one file each, the model's
|
||||
// output as it is, under the state directory — `prototypes/<sessionId>/<n>-<slug>.html` — never in
|
||||
// the project, and only this user may read them (a page is a page: it may say anything). The web
|
||||
// UI renders the same fences live; here `/prototype` lists them and a browser opens the file.
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { paths } from "../config/paths.ts"
|
||||
import { titleFor, type PrototypeKind } from "./fence.ts"
|
||||
|
||||
export interface SavedPrototype {
|
||||
n: number
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export const prototypeDir = (sessionId: string) => join(paths.state, "prototypes", sessionId.replace(/[^\w.-]/g, "_"))
|
||||
|
||||
const FILE = /^(\d+)-(.*)\.html$/
|
||||
|
||||
/** A file name's slug from a title: letters and digits, dashes between, at most 48; the kind
|
||||
* when the title says nothing. */
|
||||
export function slugOf(title: string, kind: PrototypeKind): string {
|
||||
const s = title
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/, "")
|
||||
return s || kind
|
||||
}
|
||||
|
||||
/** This session's prototypes, in the order they were saved. */
|
||||
export function listPrototypes(sessionId: string): SavedPrototype[] {
|
||||
const dir = prototypeDir(sessionId)
|
||||
if (!existsSync(dir)) return []
|
||||
return readdirSync(dir)
|
||||
.map((name) => ({ name, m: FILE.exec(name) }))
|
||||
.filter((f): f is { name: string; m: RegExpExecArray } => f.m !== null)
|
||||
.map((f) => ({ n: Number(f.m[1]), name: f.name, path: join(dir, f.name) }))
|
||||
.sort((a, b) => a.n - b.n)
|
||||
}
|
||||
|
||||
const hash = (s: string) => createHash("sha256").update(s).digest("hex")
|
||||
|
||||
/** Save a prototype the first time it is seen: the fence's code written as it is, mode 0600, as
|
||||
* the next number in the session's directory. The same code seen again (a session reopened,
|
||||
* a reply redrawn) is the file already there. Returns the path. */
|
||||
export function savePrototype(sessionId: string, code: string, o: { kind: PrototypeKind; title: string }): string {
|
||||
const dir = prototypeDir(sessionId)
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
const have = listPrototypes(sessionId)
|
||||
const h = hash(code)
|
||||
for (const f of have) {
|
||||
try {
|
||||
if (hash(readFileSync(f.path, "utf8")) === h) return f.path
|
||||
} catch {}
|
||||
}
|
||||
const n = (have.at(-1)?.n ?? 0) + 1
|
||||
const path = join(dir, `${n}-${slugOf(titleFor(code, o.title), o.kind)}.html`)
|
||||
writeFileSync(path, code, { mode: 0o600 })
|
||||
return path
|
||||
}
|
||||
@@ -63,14 +63,19 @@ export function toOpenAIMessages(system: string, messages: Message[], vision: bo
|
||||
/** Reassembles streamed tool-call fragments, keyed by index (the only field on every fragment). */
|
||||
export class ToolCallAccumulator {
|
||||
private calls = new Map<number, { id: string; name: string; args: string }>()
|
||||
/** The index of the call started last: a delta without one continues it (quirks.json
|
||||
* tool-call-without-index; conformance toolcall_index.json). */
|
||||
private last = 0
|
||||
|
||||
feed(fragments: unknown): { index: number; name?: string; argsDelta: string }[] {
|
||||
const deltas: { index: number; name?: string; argsDelta: string }[] = []
|
||||
if (!Array.isArray(fragments)) return deltas
|
||||
for (const f of fragments as any[]) {
|
||||
if (!f || typeof f !== "object") continue
|
||||
// Some servers omit index when there is only one call.
|
||||
const index = Number.isInteger(f.index) ? (f.index as number) : 0
|
||||
// Some servers omit the index: the fragment belongs to the call started last, and the
|
||||
// first fragment of a stream without one starts call 0.
|
||||
const index = Number.isInteger(f.index) ? (f.index as number) : this.last
|
||||
this.last = index
|
||||
let call = this.calls.get(index)
|
||||
if (!call) this.calls.set(index, (call = { id: "", name: "", args: "" }))
|
||||
if (f.id) call.id = String(f.id)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// Server-sent events → parsed JSON objects. Keep-alive comments and non-data lines are skipped;
|
||||
// a malformed frame is skipped rather than killing the reply (LLeMbas openai_client._stream_once).
|
||||
// a malformed frame is skipped rather than killing the reply (LLeMbas generation.py, the same rule).
|
||||
// Errors are surfaced in every shape seen in the wild — see the comments in `line()`.
|
||||
export async function* sseJson(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: unknown }> {
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface CommandInfo {
|
||||
* the two equal). A custom command or a skill of one of these names is not a command — in the TUI
|
||||
* the built-in wins — so it is not one here either, even where the built-in is not offered. */
|
||||
export const TUI_COMMAND_NAMES = [
|
||||
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
|
||||
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
|
||||
"sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
|
||||
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
|
||||
"icons", "trust", "reload", "restart", "quit", "exit", "q",
|
||||
|
||||
+55
-27
@@ -8,7 +8,9 @@ import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
|
||||
import type { Effort, Mode } from "../config/schema.ts"
|
||||
import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.ts"
|
||||
import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
|
||||
import type { Todo } from "../tool/todo.ts"
|
||||
import type { Plan } from "../plan/plan.ts"
|
||||
import { LOOP } from "../harness.ts"
|
||||
import { fillPrompt } from "../prompt/fill.ts"
|
||||
import { splitPurpose, toSpec, ToolError, type Tool, type ToolContext, type ToolResult } from "../tool/tool.ts"
|
||||
import { fill, promptText } from "../prompt/assemble.ts"
|
||||
import { resolveCall } from "../tool/names.ts"
|
||||
@@ -25,7 +27,8 @@ export interface EngineOptions {
|
||||
tools: Tool[]
|
||||
store?: Store
|
||||
sessionId?: string
|
||||
system: (mode: Mode, model: ResolvedModel) => string
|
||||
/** The system prompt for a step; `plan`: the session's plan, for `{{plan_context}}`. */
|
||||
system: (mode: Mode, model: ResolvedModel, plan?: Plan) => string
|
||||
mode: Mode
|
||||
effort: Effort | null
|
||||
perm: Omit<PermContext, "mode">
|
||||
@@ -50,7 +53,7 @@ export interface PromptMeta {
|
||||
attachments?: AttachmentInfo[]
|
||||
}
|
||||
|
||||
const MAX_PARALLEL = 4
|
||||
const MAX_PARALLEL = LOOP.parallel.max
|
||||
const PRUNED = "[This output was removed to save context. Run the tool again if you need it.]"
|
||||
/** What a message sent mid-turn starts with. Said, not hidden: Hermes found that a model refuses an
|
||||
* unmarked one as an injection. A resumed session shows the message without it. */
|
||||
@@ -79,9 +82,11 @@ const CUT_THINKING =
|
||||
"Your last reply hit the output limit while you were still thinking, so nothing was said or done. Think less this time: act now with a tool call, or answer."
|
||||
const CUT_TEXT = "Your last reply was cut off at the output limit. Carry on from exactly where it stopped."
|
||||
/** Replies in a row that say nothing (only thinking, or cut off) get asked again this many times. */
|
||||
const MAX_NUDGES = 2
|
||||
const wrapUpText = (why: string) =>
|
||||
`You have reached the ${why} for this reply. Do not call any more tools. Answer now from what you have: say what is done, what is not, and what you would do next.`
|
||||
const MAX_NUDGES = LOOP.nudges.max
|
||||
/** The wrap-up: the spec's words, with which limit was reached. */
|
||||
const wrapUpText = (limit: string) => fillPrompt(LOOP.wrap_up.message, { limit })
|
||||
/** The session_meta key the plan is kept under. */
|
||||
export const PLAN_META = "plan"
|
||||
|
||||
export class Engine {
|
||||
messages: Message[] = []
|
||||
@@ -113,8 +118,9 @@ export class Engine {
|
||||
private turnSteers: string[] = []
|
||||
/** busy_input: steer — the inbox goes in at the next step; queue — it waits for the task to end. */
|
||||
busyInput: "steer" | "queue" = "steer"
|
||||
/** The todo tool's list, for this session. */
|
||||
todos: { items: Todo[]; changed?: (items: Todo[]) => void } = { items: [] }
|
||||
/** The session's plan (plan_write / plan_update), kept with the session and put into the
|
||||
* prompt on every step while it exists. */
|
||||
plan: Plan | undefined
|
||||
private sessionRules: Rule[] = []
|
||||
private recentCalls: string[] = []
|
||||
/** Tools refused with no one able to approve them: no longer offered. */
|
||||
@@ -131,7 +137,22 @@ export class Engine {
|
||||
this.model = o.model
|
||||
this.client = o.client
|
||||
this.sessionId = o.sessionId
|
||||
this.todos.changed = (items) => o.bus.emit({ type: "todos", items })
|
||||
this.plan = this.storedPlan()
|
||||
}
|
||||
|
||||
/** The plan the store holds for this session, if any. */
|
||||
private storedPlan(): Plan | undefined {
|
||||
if (!this.o.store || !this.sessionId) return undefined
|
||||
const p = this.o.store.meta<Plan | null>(this.sessionId, PLAN_META)
|
||||
return p && typeof p === "object" && "version" in p ? p : undefined
|
||||
}
|
||||
|
||||
/** Replace the plan (null clears it): kept with the session, and said on the bus so every
|
||||
* front end's card follows. */
|
||||
setPlan(plan: Plan | null) {
|
||||
this.plan = plan ?? undefined
|
||||
if (this.o.store && this.sessionId) this.o.store.setMeta(this.sessionId, PLAN_META, plan)
|
||||
this.o.bus.emit({ type: "plan", plan: this.plan })
|
||||
}
|
||||
|
||||
get effort(): Effort | null {
|
||||
@@ -162,8 +183,10 @@ export class Engine {
|
||||
this.sessionRules = []
|
||||
this.recentCalls = []
|
||||
this.withdrawn.clear()
|
||||
this.todos.items = []
|
||||
this.o.bus.emit({ type: "todos", items: [] })
|
||||
// A new session's plan is whatever the store holds for it: none for a fresh one, the old one
|
||||
// on a resume.
|
||||
this.plan = this.storedPlan()
|
||||
this.o.bus.emit({ type: "plan", plan: this.plan })
|
||||
this.o.toolCtx.readFiles.clear()
|
||||
this.o.toolCtx.fileStamps.clear()
|
||||
}
|
||||
@@ -302,18 +325,20 @@ export class Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/** Which budget this prompt has run past, said for the user, or "" while there is room. */
|
||||
private overBudget(started: number): string {
|
||||
/** Which budget this prompt has run past — `limit` names it for the wrap-up message, `said` is
|
||||
* for the user's notice — or undefined while there is room. */
|
||||
private overBudget(started: number): { limit: string; said: string } | undefined {
|
||||
const b = this.o.budget
|
||||
if (!b) return ""
|
||||
if (!b) return undefined
|
||||
const spent = (performance.now() - started - this.turnWaitedMs) / 1000
|
||||
if (b.wall_seconds && spent > b.wall_seconds) return `after ${duration(spent * 1000, true)}`
|
||||
if (b.output_bytes && this.turnOutputBytes > b.output_bytes) return "with too much tool output to read"
|
||||
if (b.completion_tokens && this.turnCompletion > b.completion_tokens) return `after writing about ${this.turnCompletion.toLocaleString("en")} tokens`
|
||||
return ""
|
||||
if (b.wall_seconds && spent > b.wall_seconds) return { limit: "time budget", said: `after ${duration(spent * 1000, true)}` }
|
||||
if (b.output_bytes && this.turnOutputBytes > b.output_bytes) return { limit: "tool output budget", said: "with too much tool output to read" }
|
||||
if (b.completion_tokens && this.turnCompletion > b.completion_tokens) return { limit: "output budget", said: `after writing about ${this.turnCompletion.toLocaleString("en")} tokens` }
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Tools a model can use at all: view_image only with vision. */
|
||||
/** Tools a model can use at all: view_image only with vision, plan_submit only in plan mode
|
||||
* (`requires: "plan"`; plan_write and plan_update are offered in every mode). */
|
||||
private offered(name: string): boolean {
|
||||
const t = this.o.tools.find((x) => x.name === name)
|
||||
if (name === "task" && !this.o.spawn) return false
|
||||
@@ -424,14 +449,14 @@ export class Engine {
|
||||
this.autoCompactions = 0
|
||||
const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
|
||||
const allSpecs = this.o.tools.map(toSpec)
|
||||
const maxSteps = this.o.maxSteps ?? 200
|
||||
const maxSteps = this.o.maxSteps ?? LOOP.steps.default
|
||||
const started = performance.now()
|
||||
this.turnWaitedMs = 0
|
||||
this.turnOutputBytes = 0
|
||||
this.turnCompletion = 0
|
||||
// Why the prompt is being wound up early, once a budget has run out: then, as past the step
|
||||
// limit, the tools are withdrawn and the model answers from what it has.
|
||||
let ranOut = ""
|
||||
let ranOut: { limit: string; said: string } | undefined
|
||||
let nudges = 0
|
||||
let midRetries = 0
|
||||
const tried = new Set<string>()
|
||||
@@ -440,9 +465,9 @@ export class Engine {
|
||||
// Checked between steps, never mid-reply: a reply cut off mid-sentence is worth nothing.
|
||||
if (!ranOut && step > 1) {
|
||||
ranOut = this.overBudget(started)
|
||||
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut} — the model answers from what it has; send a message to carry on` })
|
||||
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` })
|
||||
}
|
||||
const wrapUp = step > maxSteps || ranOut !== ""
|
||||
const wrapUp = step > maxSteps || ranOut !== undefined
|
||||
if (!wrapUp) await this.manageContext(signal)
|
||||
// After compacting, so what was just sent is never folded into a summary.
|
||||
if (!wrapUp) this.deliverInbox()
|
||||
@@ -457,8 +482,8 @@ export class Engine {
|
||||
let partial = ""
|
||||
try {
|
||||
const stream = this.client.stream({
|
||||
system: this.o.system(this.mode, this.model),
|
||||
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? "budget" : "step limit") }] }] : this.messages),
|
||||
system: this.o.system(this.mode, this.model, this.plan),
|
||||
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? ranOut.limit : "step limit") }] }] : this.messages),
|
||||
tools: wrapUp ? [] : allSpecs.filter((s) => !this.withdrawn.has(s.name) && this.offered(s.name)),
|
||||
effort: this.effort,
|
||||
signal,
|
||||
@@ -581,12 +606,15 @@ export class Engine {
|
||||
...this.o.toolCtx,
|
||||
signal,
|
||||
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined,
|
||||
plan: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
|
||||
plan: {
|
||||
get: () => this.plan,
|
||||
set: (p) => this.setPlan(p),
|
||||
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
|
||||
},
|
||||
setMode: (m) => {
|
||||
this.mode = m
|
||||
bus.emit({ type: "mode", mode: m })
|
||||
},
|
||||
todos: this.todos,
|
||||
spawn: this.o.spawn,
|
||||
}
|
||||
// Consecutive ordinary calls run together (up to MAX_PARALLEL); an exclusive one — it waits
|
||||
|
||||
@@ -115,6 +115,20 @@ export class Store {
|
||||
this.db.query("UPDATE messages SET kind = ? WHERE session_id = ? AND id BETWEEN ? AND ? AND kind = ?").run(kind, sessionId, fromId, toId, other)
|
||||
}
|
||||
|
||||
/** The row of the user message that started turn `turnId` (kept with the message, engine.ts),
|
||||
* or undefined for a turn this session does not have. */
|
||||
turnRow(sessionId: string, turnId: string): number | undefined {
|
||||
// The LIKE is a prefilter; the message itself says.
|
||||
const rows = this.db.query("SELECT id, json FROM messages WHERE session_id = ? AND role = 'user' AND kind = 'message' AND json LIKE ? ORDER BY id").all(sessionId, `%${JSON.stringify(turnId)}%`) as { id: number; json: string }[]
|
||||
return rows.find((r) => (JSON.parse(r.json) as Message).role === "user" && (JSON.parse(r.json) as { turnId?: string }).turnId === turnId)?.id
|
||||
}
|
||||
|
||||
/** How many turns start at row `fromId` or after it: the visible user messages that carry a turn id. */
|
||||
turnsFrom(sessionId: string, fromId: number): number {
|
||||
const rows = this.db.query("SELECT json FROM messages WHERE session_id = ? AND role = 'user' AND kind = 'message' AND id >= ?").all(sessionId, fromId) as { json: string }[]
|
||||
return rows.filter((r) => typeof (JSON.parse(r.json) as { turnId?: string }).turnId === "string").length
|
||||
}
|
||||
|
||||
/** Record a compaction: what the model sees from here on starts with this summary. */
|
||||
compaction(sessionId: string, summary: string) {
|
||||
this.db
|
||||
|
||||
+35
-2
@@ -8,6 +8,8 @@ import type { Store } from "./store.ts"
|
||||
|
||||
interface Turn {
|
||||
prompt: string
|
||||
/** Its id: the one it was given (the web UI's message id), or the one made here. */
|
||||
turnId: string
|
||||
/** engine.messages.length before the prompt. */
|
||||
at: number
|
||||
before?: string
|
||||
@@ -56,8 +58,8 @@ export class Turns {
|
||||
const before = this.snaps?.track()
|
||||
this.base ??= before
|
||||
const turnId = meta.turnId || crypto.randomUUID()
|
||||
const turn: Turn = { prompt: text, at: this.engine.messages.length, before, firstRow: this.engine.lastRowId + 1, lastRow: 0 }
|
||||
this.current = { ...turn, turnId }
|
||||
const turn: Turn = { prompt: text, turnId, at: this.engine.messages.length, before, firstRow: this.engine.lastRowId + 1, lastRow: 0 }
|
||||
this.current = { ...turn }
|
||||
this.engine.o.bus.emit({ type: "task", state: "start", turnId })
|
||||
try {
|
||||
return await this.engine.prompt(text, extra, shown, { ...meta, turnId })
|
||||
@@ -113,6 +115,37 @@ export class Turns {
|
||||
return { prompt: u.turn.prompt, files }
|
||||
}
|
||||
|
||||
/** `_lembas/session/rewind`: the turn `turnId` and every later one leave the conversation the
|
||||
* model sees and the history — their rows re-kinded as /undo re-kinds a turn's, the messages
|
||||
* dropped — and the files are left as they are (the client says so beside its control). The
|
||||
* number of turns reverted, or undefined for a turn this session does not have. Not while a
|
||||
* prompt runs: the caller checks. */
|
||||
rewind(turnId: string): number | undefined {
|
||||
const sessionId = this.engine.sessionId
|
||||
const i = this.done.findIndex((t) => t.turnId === turnId)
|
||||
if (i >= 0) {
|
||||
const dropped = this.done.splice(i)
|
||||
const first = dropped[0]!
|
||||
const last = dropped[dropped.length - 1]!
|
||||
this.engine.messages.splice(first.at)
|
||||
if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted")
|
||||
this.undone = []
|
||||
return dropped.length
|
||||
}
|
||||
// A turn run before this process had the session (opened from the store): by its row. Every
|
||||
// turn run here came after it, so they all go too.
|
||||
if (!this.store || !sessionId) return undefined
|
||||
const row = this.store.turnRow(sessionId, turnId)
|
||||
if (row === undefined) return undefined
|
||||
const count = this.store.turnsFrom(sessionId, row)
|
||||
this.store.setKind(sessionId, row, Number.MAX_SAFE_INTEGER, "reverted")
|
||||
const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId)
|
||||
if (at >= 0) this.engine.messages.splice(at)
|
||||
this.done = []
|
||||
this.undone = []
|
||||
return count
|
||||
}
|
||||
|
||||
/** Everything changed since the session's first prompt, as a unified diff. */
|
||||
diff(): string | undefined {
|
||||
if (!this.snaps || !this.base) return undefined
|
||||
|
||||
@@ -102,4 +102,3 @@ export class Settings {
|
||||
}
|
||||
}
|
||||
|
||||
export const projectConfigFile = (dir: string) => join(dir, "config.yaml")
|
||||
File renamed without changes.
+7
-3
@@ -2,7 +2,7 @@
|
||||
// Wording of knowledge_search after LLeMbas's tool.knowledge prompt (© Jaroslav Beneš,
|
||||
// MIT): look there before the web, and read the document before answering from it.
|
||||
import { z } from "zod"
|
||||
import { SNIPPET_CHARS } from "../library/store.ts"
|
||||
import { resolveBase, SNIPPET_CHARS } from "../library/store.ts"
|
||||
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
|
||||
import { described } from "../harness.ts"
|
||||
|
||||
@@ -104,8 +104,12 @@ export const knowledgeSearchTool = defineTool({
|
||||
permission: (a) => ({ permission: "knowledge", class: "read", patterns: [a.base ?? "*"] }),
|
||||
async run(a, ctx) {
|
||||
const L = lib(ctx)
|
||||
const bases = a.base ? [a.base] : L.bases
|
||||
if (a.base && L.bases && !L.bases.includes(a.base)) throw new ToolError(`"${a.base}" is not one of this project's knowledge bases: ${L.bases.join(", ")}`)
|
||||
// The bases in scope (all of this machine's, or the project's `knowledge:`), and `base` read
|
||||
// against them as LLeMbas reads it: by name whatever its case, an unknown one named back.
|
||||
const inScope = L.lib.bases().filter((b) => !L.bases || L.bases.includes(b.name)).map((b) => ({ id: b.name, name: b.name }))
|
||||
const resolved = resolveBase(inScope, a.base)
|
||||
if ("error" in resolved) throw new ToolError(resolved.error)
|
||||
const bases = resolved.ids
|
||||
let note = ""
|
||||
let hits
|
||||
try {
|
||||
|
||||
+9
-1
@@ -3,11 +3,13 @@
|
||||
// agent file, a model trained on the old name — runs the tool it now is. Only the CLI's own
|
||||
// former names: LLeMbas's differ in their arguments too, and are LLeMbas's to read.
|
||||
import { TOOL_SPECS } from "../harness.ts"
|
||||
import { fromTodos, isPlan } from "../plan/plan.ts"
|
||||
|
||||
const RENAMED: Record<string, string> = {}
|
||||
const ARGUMENTS_RENAMED: Record<string, Record<string, string>> = {}
|
||||
for (const t of Object.values(TOOL_SPECS)) {
|
||||
// notes became three tools; which one depends on the call's action (below).
|
||||
// notes became three tools; which one depends on the call's action (below). todo took a
|
||||
// different argument (todos[]), converted below.
|
||||
for (const old of t.aliases.cli) if (old !== "notes") RENAMED[old] = t.name
|
||||
if (t.argument_aliases) ARGUMENTS_RENAMED[t.name] = t.argument_aliases
|
||||
}
|
||||
@@ -24,6 +26,12 @@ export function resolveCall(name: string, raw: unknown): { name: string; raw: un
|
||||
else to = "note_manage"
|
||||
args = to === "note_manage" ? obj : rest
|
||||
}
|
||||
// todo's list is one phase of the plan now (the spec's one argument conversion, conformance
|
||||
// plan.json `todos`): the whole plan is sent, as plan_write takes it.
|
||||
if (name === "todo" && obj) {
|
||||
const plan = fromTodos(obj.todos)
|
||||
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
|
||||
}
|
||||
const renamed = ARGUMENTS_RENAMED[to]
|
||||
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
|
||||
const a = { ...(args as Record<string, unknown>) }
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs"
|
||||
import { z } from "zod"
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import { absPath, defineTool, showPath, ToolError } from "./tool.ts"
|
||||
import { described } from "../harness.ts"
|
||||
|
||||
export type PlanReply = { kind: "approve"; mode: Extract<Mode, "edit" | "manual"> } | { kind: "revise"; feedback: string } | { kind: "back"; text: string } | { kind: "dismissed" }
|
||||
|
||||
/** What the model is told when nobody can approve (also where the client turns out to have no plan
|
||||
* card). */
|
||||
export const nobodyToApprove = (path: string) => `Nobody is present to approve the plan. It is saved at ${path}. Stop here: say the plan is ready and where it is. Make no changes.`
|
||||
|
||||
export const planSubmitTool = defineTool({
|
||||
...described("plan_submit"),
|
||||
purpose: false,
|
||||
access: "plan_submit",
|
||||
requires: "plan",
|
||||
exclusive: true,
|
||||
schema: z.object({ path: z.string() }),
|
||||
permission: () => ({ permission: "plan_submit", class: "interact", patterns: ["*"] }),
|
||||
async run(a, ctx) {
|
||||
const abs = absPath(a.path, ctx)
|
||||
const plans = ctx.planDir
|
||||
if (plans && !abs.startsWith(plans + "/")) throw new ToolError(`The plan must be a file under ${showPath(plans, ctx)}; ${showPath(abs, ctx)} is not.`)
|
||||
if (!existsSync(abs)) throw new ToolError(`${showPath(abs, ctx)} does not exist — write the plan first.`)
|
||||
// By where it really is: a link under the plans directory could point anywhere.
|
||||
if (plans && !realpathSync(abs).startsWith(realpathSync(plans) + "/")) throw new ToolError(`${showPath(abs, ctx)} leads out of the plans directory.`)
|
||||
const text = readFileSync(abs, "utf8")
|
||||
if (!ctx.plan)
|
||||
return {
|
||||
output: nobodyToApprove(showPath(abs, ctx)),
|
||||
title: `${showPath(abs, ctx)} · saved, not approved`,
|
||||
}
|
||||
const r = await ctx.plan({ path: showPath(abs, ctx), text }, ctx.callId)
|
||||
const shown = showPath(abs, ctx)
|
||||
switch (r.kind) {
|
||||
case "approve":
|
||||
ctx.setMode?.(r.mode)
|
||||
return {
|
||||
output: `The user approved the plan in ${shown}. The permission mode is now ${r.mode}. Implement it now, step by step as written, keeping a todo list that follows its steps. If you find the plan wrong somewhere, say so and adjust rather than follow it into a mistake.`,
|
||||
title: `${shown} · approved → ${r.mode}`,
|
||||
}
|
||||
case "revise":
|
||||
return { output: `The user wants the plan revised: ${r.feedback}\nUpdate ${shown}, then call plan_submit again.`, title: `${shown} · revise` }
|
||||
case "back":
|
||||
return { output: `Before deciding, the user asks: "${r.text}"\nAnswer it in your reply, revise ${shown} if the answer changes it, then call plan_submit again.`, title: `${shown} · asked back` }
|
||||
case "dismissed":
|
||||
return { output: `The user closed the plan without deciding. Stay in plan mode; say the plan is at ${shown} and wait.`, title: `${shown} · not decided` }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
// plan_submit: the plan written with plan_write, presented for approval — plan mode only, where
|
||||
// it is the approval. The plan itself is the session's (src/plan/plan.ts); a copy goes under the
|
||||
// project's plans directory for the record, since that is the one place plan mode may write.
|
||||
import { mkdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { z } from "zod"
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import { described } from "../harness.ts"
|
||||
import { render, type Plan } from "../plan/plan.ts"
|
||||
import { defineTool, showPath, ToolError } from "./tool.ts"
|
||||
|
||||
export type PlanReply = { kind: "approve"; mode: Extract<Mode, "edit" | "manual"> } | { kind: "revise"; feedback: string } | { kind: "back"; text: string } | { kind: "dismissed" }
|
||||
|
||||
/** What is put to the user: the plan, its text as the card draws it, and the file it was saved to
|
||||
* for the record (relative to the project), or "" where there is no plans directory. */
|
||||
export interface PlanRequest {
|
||||
path: string
|
||||
text: string
|
||||
plan: Plan
|
||||
}
|
||||
|
||||
/** What the model is told when nobody can approve (also where the client turns out to have no plan
|
||||
* card). */
|
||||
export const nobodyToApprove = (path: string) => `Nobody is present to approve the plan.${path ? ` It is saved at ${path}.` : ""} Stop here: say the plan is ready${path ? " and where it is" : ""}. Make no changes.`
|
||||
|
||||
const slug = (title: string) =>
|
||||
title
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "plan"
|
||||
|
||||
export const planSubmitTool = defineTool({
|
||||
...described("plan_submit"),
|
||||
purpose: false,
|
||||
access: "plan_submit",
|
||||
requires: "plan",
|
||||
exclusive: true,
|
||||
schema: z.object({ path: z.string().optional() }),
|
||||
permission: () => ({ permission: "plan_submit", class: "interact", patterns: ["*"] }),
|
||||
async run(_a, ctx) {
|
||||
const plan = ctx.plan?.get()
|
||||
if (!plan) throw new ToolError("There is no plan yet. Write it with plan_write, then call plan_submit.")
|
||||
const text = render(plan)
|
||||
// For the record: the plans directory is the one place plan mode writes (made on the way, as
|
||||
// the write tool made it before), and a plan that was approved is worth finding again. Nothing
|
||||
// waits on it: a directory that cannot be written is not a reason to lose the approval.
|
||||
let path = ""
|
||||
if (ctx.planDir) {
|
||||
const abs = join(ctx.planDir, `${new Date().toISOString().slice(0, 10)}-${slug(plan.title)}.md`)
|
||||
try {
|
||||
mkdirSync(ctx.planDir, { recursive: true })
|
||||
writeFileSync(abs, text + "\n")
|
||||
path = showPath(abs, ctx)
|
||||
} catch {}
|
||||
}
|
||||
const saved = path ? ` It is saved at ${path}.` : ""
|
||||
if (!ctx.plan?.ask) return { output: nobodyToApprove(path), title: `${plan.title} · saved, not approved` }
|
||||
const r = await ctx.plan.ask({ path, text, plan }, ctx.callId)
|
||||
switch (r.kind) {
|
||||
case "approve":
|
||||
ctx.setMode?.(r.mode)
|
||||
return {
|
||||
output: `The user approved the plan.${saved} The permission mode is now ${r.mode}. Implement it now, phase by phase as written, keeping the plan current with plan_update: a task doing when you start it, done once you have checked it. If you find the plan wrong somewhere, say so and adjust rather than follow it into a mistake.`,
|
||||
title: `${plan.title} · approved → ${r.mode}`,
|
||||
}
|
||||
case "revise":
|
||||
return { output: `The user wants the plan revised: ${r.feedback}\nChange it with plan_write or plan_update, then call plan_submit again.`, title: `${plan.title} · revise` }
|
||||
case "back":
|
||||
return { output: `Before deciding, the user asks: "${r.text}"\nAnswer it in your reply, revise the plan if the answer changes it, then call plan_submit again.`, title: `${plan.title} · asked back` }
|
||||
case "dismissed":
|
||||
return { output: `The user closed the plan without deciding. Stay in plan mode; say the plan is ready${saved} and wait.`, title: `${plan.title} · not decided` }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// plan_update: the plan changed as the work goes — a task marked doing or done, one dropped with
|
||||
// why, work added, a finding recorded. Several changes in one call; an unknown id is an error
|
||||
// naming the known ones, and then nothing changes (src/plan/plan.ts apply).
|
||||
import { z } from "zod"
|
||||
import { described } from "../harness.ts"
|
||||
import { apply, OBJECTIVE_STATUSES, PHASE_STATUSES, render, TASK_STATUSES } from "../plan/plan.ts"
|
||||
import { planTitle } from "./plan_write.ts"
|
||||
import { defineTool } from "./tool.ts"
|
||||
|
||||
const asList = (v: unknown) => (v === undefined || v === null || Array.isArray(v) ? v : [v])
|
||||
|
||||
export const planUpdateSchema = z.object({
|
||||
tasks: z.preprocess(asList, z.array(z.object({ id: z.string().min(1), status: z.enum(TASK_STATUSES).optional(), note: z.string().max(300).optional() }))).optional(),
|
||||
objectives: z.preprocess(asList, z.array(z.object({ id: z.string().min(1), status: z.enum(OBJECTIVE_STATUSES) }))).optional(),
|
||||
phases: z.preprocess(asList, z.array(z.object({ id: z.string().min(1), status: z.enum(PHASE_STATUSES) }))).optional(),
|
||||
add_tasks: z.preprocess(asList, z.array(z.object({ phase: z.string().min(1), text: z.string().min(1).max(300), after: z.string().optional() }))).optional(),
|
||||
add_findings: z.preprocess(asList, z.array(z.string().min(1).max(300))).optional(),
|
||||
})
|
||||
|
||||
export const planUpdateTool = defineTool({
|
||||
...described("plan_update"),
|
||||
purpose: false,
|
||||
access: "plan",
|
||||
schema: planUpdateSchema,
|
||||
permission: () => ({ permission: "plan", class: "interact", patterns: ["*"] }),
|
||||
async run(a, ctx) {
|
||||
const current = ctx.plan?.get()
|
||||
if (!current) return { output: "There is no plan to update. Write one with plan_write first.", isError: true }
|
||||
const r = apply(current, a)
|
||||
if ("error" in r) return { output: r.error, isError: true }
|
||||
ctx.plan?.set(r.plan)
|
||||
return { output: render(r.plan), title: planTitle(r.plan) }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// plan_write: the plan for this work, written whole (it replaces the previous one). The structure,
|
||||
// its ids and bounds are src/plan/plan.ts; the engine keeps the plan per session and puts it into
|
||||
// the prompt on every step while it exists.
|
||||
import { z } from "zod"
|
||||
import { described } from "../harness.ts"
|
||||
import { isPlan, normalise, OBJECTIVE_STATUSES, PHASE_STATUSES, progress, render, TASK_STATUSES, type Plan } from "../plan/plan.ts"
|
||||
import { defineTool, ToolError } from "./tool.ts"
|
||||
|
||||
// Small models send a task as a bare string, a phase as its title, a finding as `{text}`: all of
|
||||
// it is accepted here and read by normalise, as LLeMbas does.
|
||||
const asText = (v: unknown) => (v && typeof v === "object" && "text" in v ? String((v as { text: unknown }).text ?? "") : v)
|
||||
const asItem = (v: unknown) => (typeof v === "string" ? { text: v } : v)
|
||||
const asPhase = (v: unknown) => (typeof v === "string" ? { title: v, tasks: [] } : v)
|
||||
const asList = (v: unknown) => (v === undefined || v === null || Array.isArray(v) ? v : [v])
|
||||
|
||||
export const planWriteSchema = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
summary: z.string().max(300).optional(),
|
||||
findings: z.preprocess(asList, z.array(z.preprocess(asText, z.string().max(300))).max(20)).optional(),
|
||||
objectives: z
|
||||
.preprocess(asList, z.array(z.preprocess(asItem, z.object({ text: z.string().min(1).max(300), status: z.enum(OBJECTIVE_STATUSES).default("open") }))).max(8))
|
||||
.optional(),
|
||||
phases: z.preprocess(
|
||||
asList,
|
||||
z
|
||||
.array(
|
||||
z.preprocess(
|
||||
asPhase,
|
||||
z.object({
|
||||
title: z.string().max(120).optional(),
|
||||
status: z.enum(PHASE_STATUSES).default("pending"),
|
||||
tasks: z.preprocess(asList, z.array(z.preprocess(asItem, z.object({ text: z.string().min(1).max(300), status: z.enum(TASK_STATUSES).default("todo"), note: z.string().max(300).optional() }))).max(12)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.max(8),
|
||||
),
|
||||
})
|
||||
|
||||
/** The tool's title line: how much of the plan is done. */
|
||||
export const planTitle = (plan: Plan) => {
|
||||
const { done, total } = progress(plan)
|
||||
return `${done}/${total} done`
|
||||
}
|
||||
|
||||
export const planWriteTool = defineTool({
|
||||
...described("plan_write"),
|
||||
purpose: false,
|
||||
access: "plan",
|
||||
schema: planWriteSchema,
|
||||
permission: () => ({ permission: "plan", class: "interact", patterns: ["*"] }),
|
||||
async run(a, ctx) {
|
||||
const plan = normalise(a)
|
||||
if (!isPlan(plan)) throw new ToolError("The plan is empty: give it a title and at least one phase with tasks.")
|
||||
ctx.plan?.set(plan)
|
||||
return { output: render(plan), title: planTitle(plan) }
|
||||
},
|
||||
})
|
||||
@@ -2,12 +2,13 @@ import { applyPatchTool } from "./apply_patch.ts"
|
||||
import { bashKillTool, bashListTool, bashOutputTool, bashTool } from "./bash.ts"
|
||||
import { editTool } from "./edit.ts"
|
||||
import { multieditTool } from "./multiedit.ts"
|
||||
import { askUserTool } from "./question.ts"
|
||||
import { askUserTool } from "./ask_user.ts"
|
||||
import { readTool } from "./read.ts"
|
||||
import { globTool, grepTool, listTool } from "./search.ts"
|
||||
import { todoTool } from "./todo.ts"
|
||||
import { viewImageTool } from "./view_image.ts"
|
||||
import { planSubmitTool } from "./plan_exit.ts"
|
||||
import { planSubmitTool } from "./plan_submit.ts"
|
||||
import { planUpdateTool } from "./plan_update.ts"
|
||||
import { planWriteTool } from "./plan_write.ts"
|
||||
import { taskTool } from "./task.ts"
|
||||
import { decisionsTool, tasksTool } from "./project_tools.ts"
|
||||
import { webFetchTool, webSearchTool } from "./web.ts"
|
||||
@@ -35,7 +36,8 @@ export const BUILTIN_TOOLS: Tool[] = [
|
||||
webSearchTool,
|
||||
webFetchTool,
|
||||
taskTool,
|
||||
todoTool,
|
||||
planWriteTool,
|
||||
planUpdateTool,
|
||||
tasksTool,
|
||||
decisionsTool,
|
||||
askUserTool,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// A working todo list for multi-step tasks. The whole list is sent each time (OpenCode's
|
||||
// todowrite): simpler for small models than Hermes' merge-by-id, and nothing can drift.
|
||||
import { z } from "zod"
|
||||
import { defineTool } from "./tool.ts"
|
||||
import { described } from "../harness.ts"
|
||||
|
||||
export const TODO_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const
|
||||
export type Todo = { content: string; status: (typeof TODO_STATUSES)[number] }
|
||||
|
||||
const MARK: Record<Todo["status"], string> = { pending: "[ ]", in_progress: "[>]", completed: "[x]", cancelled: "[~]" }
|
||||
|
||||
export function renderTodos(items: Todo[]): string {
|
||||
return items.map((t) => `${MARK[t.status]} ${t.content}`).join("\n") || "(empty)"
|
||||
}
|
||||
|
||||
export const todoTool = defineTool({
|
||||
...described("todo"),
|
||||
purpose: false,
|
||||
access: "todo",
|
||||
schema: z.object({
|
||||
todos: z
|
||||
.array(
|
||||
z.preprocess(
|
||||
(t) => (typeof t === "string" ? { content: t, status: "pending" } : t),
|
||||
z.object({ content: z.string().min(1), status: z.enum(TODO_STATUSES).default("pending") }),
|
||||
),
|
||||
)
|
||||
.max(30),
|
||||
}),
|
||||
permission: () => ({ permission: "todo", class: "interact", patterns: ["*"] }),
|
||||
async run(a, ctx) {
|
||||
const items = a.todos as Todo[]
|
||||
if (ctx.todos) {
|
||||
ctx.todos.items = items
|
||||
ctx.todos.changed?.(items)
|
||||
}
|
||||
const done = items.filter((t) => t.status === "completed").length
|
||||
return { output: renderTodos(items), title: `${done}/${items.length} done` }
|
||||
},
|
||||
})
|
||||
+11
-5
@@ -20,7 +20,7 @@ export interface ToolContext {
|
||||
/** Instruction files (AGENTS.md / CLAUDE.md) the model has already been given. */
|
||||
instructionsSeen?: Set<string>
|
||||
/** Ask the user questions (the ask_user tool). Absent where nobody can answer. */
|
||||
question?: (req: import("./question.ts").QuestionRequest, callId?: string) => Promise<import("./question.ts").QuestionReply>
|
||||
question?: (req: import("./ask_user.ts").QuestionRequest, callId?: string) => Promise<import("./ask_user.ts").QuestionReply>
|
||||
/** Run a subagent (the task tool). Absent inside a subagent: no recursion. */
|
||||
spawn?: (agent: string, prompt: string, signal: AbortSignal, callId?: string, opts?: { worktree?: boolean; description?: string; model?: string }) => Promise<import("./task.ts").SubagentResult>
|
||||
/** The id of the call being run (set by the engine per call). */
|
||||
@@ -29,8 +29,13 @@ export interface ToolContext {
|
||||
projectDir?: string
|
||||
/** The plans directory (plan mode writes only there). */
|
||||
planDir?: string
|
||||
/** Present a plan for approval (plan_submit). Absent where nobody can answer. */
|
||||
plan?: (req: { path: string; text: string }, callId?: string) => Promise<import("./plan_exit.ts").PlanReply>
|
||||
/** The session's plan (plan_write, plan_update, plan_submit): read it, replace it (null clears),
|
||||
* and present it for approval — `ask` is absent where nobody can answer. */
|
||||
plan?: {
|
||||
get(): import("../plan/plan.ts").Plan | undefined
|
||||
set(plan: import("../plan/plan.ts").Plan | null): void
|
||||
ask?: (req: import("./plan_submit.ts").PlanRequest, callId?: string) => Promise<import("./plan_submit.ts").PlanReply>
|
||||
}
|
||||
/** Change the permission mode (plan_submit, on approval). */
|
||||
setMode?: (mode: import("../config/schema.ts").Mode) => void
|
||||
/** Web search and fetch settings (config `search:`). */
|
||||
@@ -50,6 +55,9 @@ export interface ToolContext {
|
||||
set(key: string, value: unknown, scope?: import("../config/settings.ts").Scope): import("../settings.ts").SetResult
|
||||
models(): string[]
|
||||
}
|
||||
/** The scope sent on every call to the instance's library (`_meta.lembas.scope`, harness/acp.md):
|
||||
* the session's id, where the client has said what the session may search. */
|
||||
libraryScope?: string
|
||||
/** The library: notes, and the knowledge bases this project searches (all, when
|
||||
* `bases` is undefined); `project` is the scope of this project's notes. */
|
||||
library?: {
|
||||
@@ -58,8 +66,6 @@ export interface ToolContext {
|
||||
bases?: string[]
|
||||
embedder?: import("../library/store.ts").Embedder
|
||||
}
|
||||
/** The session's todo list (the todo tool). */
|
||||
todos?: { items: import("./todo.ts").Todo[]; changed?: (items: import("./todo.ts").Todo[]) => void }
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
|
||||
+4
-3
@@ -12,7 +12,7 @@ import { PermissionPrompt } from "./components/permission.tsx"
|
||||
import { copiedMessage, copyText } from "./clipboard.ts"
|
||||
import { QuestionCard } from "./components/question.tsx"
|
||||
import { PlanCard } from "./components/plancard.tsx"
|
||||
import { Todos } from "./components/todos.tsx"
|
||||
import { PlanPanel } from "./components/plan.tsx"
|
||||
import { Prompt, type PaletteEntry } from "./components/prompt.tsx"
|
||||
import { FileIndex } from "../project/files.ts"
|
||||
import { attachmentsFor } from "../project/attach.ts"
|
||||
@@ -316,6 +316,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
},
|
||||
notice: (text, level) => view.info(text, level),
|
||||
changed: () => refresh(),
|
||||
rewound: () => view.load(app.engine.messages),
|
||||
})
|
||||
cmd.share = share
|
||||
view.setCardHooks({
|
||||
@@ -356,7 +357,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
// Not mid-task: what changes the session under it, and what sends a prompt of its own
|
||||
// (that would start a second turn on top of the running one).
|
||||
const midTask = ["new", "delete", "compact", "sessions", "model", "undo", "redo", "commit", "diff", "release", "changelog", "init", "checkpoint", "checkpoints", "branch", "reload", "review", "skill"]
|
||||
if ((busy() || app.turns.current) && (midTask.includes(c.name) || (c.name === "plan" && arg))) return (view.info("wait for the reply to finish (esc stops it)", "warn"), false)
|
||||
if ((busy() || app.turns.current) && (midTask.includes(c.name) || (c.name === "plan" && arg && arg !== "show"))) return (view.info("wait for the reply to finish (esc stops it)", "warn"), false)
|
||||
return void c.run(cmd, arg)
|
||||
}
|
||||
const cc = custom().find((x) => x.name === name)
|
||||
@@ -538,7 +539,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
<Messages />
|
||||
</scrollbox>
|
||||
<box flexShrink={0} flexDirection="column">
|
||||
<Todos />
|
||||
<PlanPanel />
|
||||
<Show when={view.state.pending}>
|
||||
<PermissionPrompt pending={view.state.pending!} />
|
||||
</Show>
|
||||
|
||||
+34
-3
@@ -18,10 +18,13 @@ import type { Ctx } from "./context.ts"
|
||||
import type { DialogSpec } from "./components/dialog.tsx"
|
||||
import { deleteEverywhere } from "../acp/share.ts"
|
||||
import { themeNames } from "./theme.ts"
|
||||
import { render } from "../plan/plan.ts"
|
||||
import { planTitle } from "../tool/plan_write.ts"
|
||||
import { findSetting, show, type Scope } from "../config/settings.ts"
|
||||
import { ingest } from "../library/ingest.ts"
|
||||
import { findSkill, skillMessage, slug } from "../skill/index.ts"
|
||||
import { PERSONALITIES, personalityText } from "../prompt/personality.ts"
|
||||
import { listPrototypes } from "../prototype/store.ts"
|
||||
import { keyMatcher } from "../voice/index.ts"
|
||||
import { PLAYER_NAMES, playerFor, recorderFor } from "../voice/audio.ts"
|
||||
|
||||
@@ -221,9 +224,18 @@ export const COMMANDS: Command[] = [
|
||||
},
|
||||
{
|
||||
name: "plan",
|
||||
args: "[task]",
|
||||
args: "[task | show | clear]",
|
||||
description: "plan mode — read, investigate, write a plan for approval; with a task, start on it",
|
||||
run(c, arg) {
|
||||
if (arg === "show") {
|
||||
const plan = c.app.engine.plan
|
||||
return c.view.info(plan ? render(plan) : "no plan in this session — the model writes one with plan_write")
|
||||
}
|
||||
if (arg === "clear") {
|
||||
if (!c.app.engine.plan) return c.view.info("no plan in this session")
|
||||
c.app.engine.setPlan(null)
|
||||
return c.view.info("plan cleared")
|
||||
}
|
||||
c.setMode("plan")
|
||||
if (arg) c.send?.(arg)
|
||||
else c.view.info("plan mode: the model reads and plans, then asks you to approve the plan. shift+tab leaves it.")
|
||||
@@ -231,11 +243,14 @@ export const COMMANDS: Command[] = [
|
||||
},
|
||||
{
|
||||
name: "plans",
|
||||
description: "this project's plans",
|
||||
description: "this project's plans, and this session's",
|
||||
run(c) {
|
||||
const live = c.app.engine.plan
|
||||
const dir = c.app.engine.o.toolCtx.planDir
|
||||
const files = dir && existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".md")).sort().reverse() : []
|
||||
if (!files.length) return c.view.info("no plans yet — /plan <task> writes one")
|
||||
const here = live ? `this session: ${live.title} (${planTitle(live)}) — /plan show` : "this session has no plan"
|
||||
if (!files.length) return c.view.info(`${here}\nno plans saved yet — /plan <task> writes one`)
|
||||
c.view.info(here)
|
||||
c.openDialog({
|
||||
title: "Plans",
|
||||
options: files.map((f) => ({ label: f, value: f, description: readFileSync(join(dir!, f), "utf8").split("\n").find((l) => l.startsWith("#"))?.replace(/^#+\s*/, "") ?? "" })),
|
||||
@@ -243,6 +258,22 @@ export const COMMANDS: Command[] = [
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prototype",
|
||||
args: "[n]",
|
||||
description: "this session's prototypes — HTML pages the model wrote, saved as files; with a number, that one's path",
|
||||
run(c, arg) {
|
||||
const id = c.app.engine.sessionId
|
||||
const list = id ? listPrototypes(id) : []
|
||||
if (!list.length) return c.view.info("no prototypes in this session yet")
|
||||
const wanted = arg.trim()
|
||||
if (wanted) {
|
||||
const one = list.find((p) => String(p.n) === wanted)
|
||||
return one ? c.view.info(one.path) : c.view.info(`no prototype ${wanted} here — ${list.map((p) => p.n).join(", ")}`, "warn")
|
||||
}
|
||||
c.view.info(list.map((p) => `${p.n} ${p.path}`).join("\n"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "review",
|
||||
args: "[head | <branch>]",
|
||||
|
||||
@@ -45,7 +45,7 @@ export function FirstRun(p: { project: Project; onDone: (r: FirstRunResult) => v
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%" paddingLeft={2} paddingTop={1}>
|
||||
<text fg={c().title} attributes={TextAttributes.BOLD}>
|
||||
☤ {theme().branding.agentName} — first time here
|
||||
{theme().branding.agentName} — first time here
|
||||
</text>
|
||||
<text fg={c().text} wrapMode="word">
|
||||
{p.project.root}
|
||||
|
||||
@@ -5,6 +5,16 @@ import { estimateTokens } from "../../provider/tokens.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
import type { Item } from "../state.ts"
|
||||
import { clipDiff } from "../diffclip.ts"
|
||||
import { TOOL_SPECS } from "../../harness.ts"
|
||||
import { resolveCall } from "../../tool/names.ts"
|
||||
import { splitPrototypes } from "../../prototype/fence.ts"
|
||||
|
||||
/** A call the transcript does not draw: a plan change (the panel above the prompt follows it) or a
|
||||
* tool the spec marks hidden — under its former name too (a stored session's `todo`). */
|
||||
const undrawn = (name: string) => {
|
||||
const block = TOOL_SPECS[resolveCall(name, {}).name]?.block
|
||||
return block === "plan" || block === "hidden"
|
||||
}
|
||||
|
||||
const FILETYPES: Record<string, string> = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", md: "markdown", zig: "zig" }
|
||||
export const filetypeOf = (diff: string) => FILETYPES[/^\+\+\+ \S*\.(\w+)/m.exec(diff)?.[1] ?? ""]
|
||||
@@ -14,7 +24,7 @@ function argSummary(args: unknown): string {
|
||||
if (!args || typeof args !== "object") return ""
|
||||
const a = args as Record<string, unknown>
|
||||
if (Array.isArray(a.questions)) return (a.questions as { header?: string }[]).map((q) => q.header).filter(Boolean).join(" · ") || "asking"
|
||||
if (Array.isArray(a.todos)) return `${a.todos.length} item${a.todos.length === 1 ? "" : "s"}`
|
||||
if (Array.isArray(a.phases)) return String(a.title ?? "").slice(0, 120)
|
||||
if (typeof a.patch === "string") return [...a.patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1]).join(", ")
|
||||
const v = a.description ?? a.command ?? a.path ?? a.pattern ?? a.id ?? ""
|
||||
return String(v).split("\n")[0]!.slice(0, 120)
|
||||
@@ -38,11 +48,33 @@ function UserMessage(p: { item: Extract<Item, { kind: "user" }> }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** A reply as markdown — except a completed prototype fence, which is saved as a file
|
||||
* (state.ts) and drawn as one line saying so in its place; while it still streams it is the
|
||||
* code block it is so far. */
|
||||
function AssistantMessage(p: { item: Extract<Item, { kind: "assistant" }> }) {
|
||||
const { theme, md } = useTui()
|
||||
const segments = () => (p.item.text.includes("```") || p.item.text.includes("~~~") ? splitPrototypes(p.item.text) : [])
|
||||
const whole = () => !segments().some((s) => s.kind === "prototype")
|
||||
return (
|
||||
<box paddingLeft={2} marginTop={1}>
|
||||
<markdown content={p.item.text.trim()} syntaxStyle={md()} streaming={p.item.streaming} fg={theme().colors.text} conceal />
|
||||
<box paddingLeft={2} marginTop={1} flexDirection="column">
|
||||
<Show when={!whole()} fallback={<markdown content={p.item.text.trim()} syntaxStyle={md()} streaming={p.item.streaming} fg={theme().colors.text} conceal />}>
|
||||
<For each={segments()}>
|
||||
{(seg, i) => {
|
||||
const saved = () => (seg.kind === "prototype" && seg.closed ? p.item.prototypes?.[seg.start] : undefined)
|
||||
const last = () => i() === segments().length - 1
|
||||
return (
|
||||
<Show
|
||||
when={saved()}
|
||||
fallback={<markdown content={(seg.kind === "text" ? seg.text : seg.raw).trim()} syntaxStyle={md()} streaming={p.item.streaming && last()} fg={theme().colors.text} conceal />}
|
||||
>
|
||||
<text fg={theme().colors.muted} wrapMode="none" truncate>
|
||||
{`prototype · ${saved()!.title || saved()!.kind} · saved to ${saved()!.path}`}
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -229,7 +261,7 @@ export function Messages() {
|
||||
<Match when={item.kind === "reasoning"}>
|
||||
<Reasoning item={item as Extract<Item, { kind: "reasoning" }>} />
|
||||
</Match>
|
||||
<Match when={item.kind === "tool"}>
|
||||
<Match when={item.kind === "tool" && !undrawn((item as Extract<Item, { kind: "tool" }>).name)}>
|
||||
<ToolLine item={item as Extract<Item, { kind: "tool" }>} />
|
||||
</Match>
|
||||
<Match when={item.kind === "notice"}>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { open, summary, type TaskStatus } from "../../plan/plan.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
|
||||
const MARK: Record<TaskStatus, string> = { todo: "☐", doing: "▸", done: "☑", dropped: "✗" }
|
||||
|
||||
/** The session's plan, above the prompt while anything on it is still to do: the active phase
|
||||
* and the count, the task being done, what is next, the last two done — at most six lines. */
|
||||
export function PlanPanel() {
|
||||
const { theme, view } = useTui()
|
||||
const c = () => theme().colors
|
||||
const digest = () => (view.state.livePlan ? summary(view.state.livePlan) : undefined)
|
||||
return (
|
||||
<Show when={view.state.livePlan && open(view.state.livePlan)}>
|
||||
<box flexDirection="column" paddingLeft={1}>
|
||||
<text fg={c().label} wrapMode="none" truncate>
|
||||
{digest()?.head}
|
||||
</text>
|
||||
<For each={digest()?.lines ?? []}>
|
||||
{(t) => (
|
||||
<text wrapMode="none" truncate fg={t.status === "doing" ? c().accent : t.status === "done" ? c().dim : c().text}>
|
||||
{" "}
|
||||
{MARK[t.status]} {t.text}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import type { PlanReply } from "../../tool/plan_exit.ts"
|
||||
import { TASK_MARK, type Plan } from "../../plan/plan.ts"
|
||||
import type { PlanReply, PlanRequest } from "../../tool/plan_submit.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
|
||||
export interface PendingPlan {
|
||||
path: string
|
||||
text: string
|
||||
/** The plan as the card draws it: render()'s lines, each with what it is, so the ids and marks
|
||||
* stay on screen (a markdown box would read `[t1]` as a link and conceal it). */
|
||||
export function planLines(plan: Plan): { kind: "title" | "text" | "phase" | "todo" | "doing" | "done" | "dropped"; text: string }[] {
|
||||
const out: ReturnType<typeof planLines> = [{ kind: "title", text: plan.title }]
|
||||
if (plan.summary) out.push({ kind: "text", text: plan.summary })
|
||||
if (plan.findings.length) out.push({ kind: "text", text: `Findings: ${plan.findings.map((f) => `[${f.id}] ${f.text}`).join("; ")}` })
|
||||
if (plan.objectives.length) out.push({ kind: "text", text: `Objectives: ${plan.objectives.map((o) => `[${o.id}] ${o.text} (${o.status})`).join("; ")}` })
|
||||
for (const ph of plan.phases) {
|
||||
out.push({ kind: "phase", text: `${ph.id} ${ph.title} (${ph.status})` })
|
||||
for (const t of ph.tasks) out.push({ kind: t.status, text: `[${t.id}] ${TASK_MARK[t.status]} ${t.text}${t.note ? ` — ${t.note}` : ""}` })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface PendingPlan extends PlanRequest {
|
||||
resolve: (r: PlanReply) => void
|
||||
}
|
||||
|
||||
@@ -19,7 +32,7 @@ const CHOICES = [
|
||||
|
||||
/** plan_submit's card: the plan itself (scroll with pgup/pgdn), then a | m | r | ?; esc keeps planning. */
|
||||
export function PlanCard(p: { pending: PendingPlan }) {
|
||||
const { theme, md, dims } = useTui()
|
||||
const { theme, dims } = useTui()
|
||||
const c = () => theme().colors
|
||||
const [sel, setSel] = createSignal(0)
|
||||
const [typing, setTyping] = createSignal<"revise" | "back">()
|
||||
@@ -54,9 +67,11 @@ export function PlanCard(p: { pending: PendingPlan }) {
|
||||
})
|
||||
|
||||
return (
|
||||
<box border borderStyle="rounded" borderColor={c().info} flexDirection="column" paddingLeft={1} paddingRight={1} title={` plan · ${p.pending.path} `} titleColor={c().info}>
|
||||
<box border borderStyle="rounded" borderColor={c().info} flexDirection="column" paddingLeft={1} paddingRight={1} title={` plan · ${p.pending.plan.title} `} titleColor={c().info}>
|
||||
<scrollbox ref={(r: ScrollBoxRenderable) => (box = r)} maxHeight={Math.max(6, Math.floor(dims().height / 2))} scrollX={false}>
|
||||
<markdown content={p.pending.text} syntaxStyle={md()} fg={c().text} conceal />
|
||||
<box flexDirection="column">
|
||||
<For each={planLines(p.pending.plan)}>{(l) => <text fg={l.kind === "title" ? c().title : l.kind === "phase" ? c().accent : l.kind === "doing" ? c().accent : l.kind === "done" || l.kind === "dropped" ? c().dim : c().text} attributes={l.kind === "title" || l.kind === "phase" ? TextAttributes.BOLD : 0} wrapMode="word">{l.text}</text>}</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<Show
|
||||
when={!typing()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import type { Answer } from "../../tool/question.ts"
|
||||
import type { Answer } from "../../tool/ask_user.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
import type { PendingQuestion } from "../state.ts"
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function Setup(p: { error: Error; onRetry: () => void; onQuit: () => void
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%" paddingLeft={2} paddingTop={1} paddingRight={2} gap={1}>
|
||||
<text fg={c().title} attributes={TextAttributes.BOLD}>
|
||||
☤ {theme().branding.agentName} — {noModel() ? "no model to talk to yet" : "the configuration has a mistake"}
|
||||
{theme().branding.agentName} — {noModel() ? "no model to talk to yet" : "the configuration has a mistake"}
|
||||
</text>
|
||||
{/* "No model configured" is what the rest of the screen explains; anything else (a model
|
||||
that does not exist, a file that does not parse) is shown as it is. */}
|
||||
|
||||
@@ -16,9 +16,9 @@ export function reading(p: { total: number; done: number; processed: number; ms:
|
||||
return parts.join(" · ")
|
||||
}
|
||||
|
||||
// Hermes' kawaii spinner: a braille frame, a face that changes every few seconds, what is going on
|
||||
// (a verb while it thinks), and the whole task's time and output tokens — from the prompt until
|
||||
// the last reply of the task, subagents included.
|
||||
// The working line: a braille frame, what is going on (a verb while it thinks), and the whole
|
||||
// task's time and output tokens — from the prompt until the last reply of the task, subagents
|
||||
// included.
|
||||
export function Spinner() {
|
||||
const { theme, view } = useTui()
|
||||
const [tick, setTick] = createSignal(0)
|
||||
@@ -41,12 +41,10 @@ export function Spinner() {
|
||||
if (s.phase === "writing") return "writing"
|
||||
return "waiting for the model"
|
||||
}
|
||||
const face = () => (view.state.phase === "thinking" ? pick(sp().thinking, 30) : pick(sp().waiting, 30))
|
||||
return (
|
||||
<box paddingLeft={1} height={1}>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span>
|
||||
<span style={{ fg: theme().colors.label }}>{face()} </span>
|
||||
<span style={{ fg: theme().colors.muted }}>{what()}… </span>
|
||||
<span style={{ fg: theme().colors.dim }}>
|
||||
{elapsed()} · {tokens()} · esc to stop
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { useTui } from "../context.ts"
|
||||
|
||||
const MARK = { pending: "☐", in_progress: "▸", completed: "☑", cancelled: "✗" } as const
|
||||
|
||||
/** The todo tool's list, above the prompt while anything on it is still open. */
|
||||
export function Todos() {
|
||||
const { theme, view } = useTui()
|
||||
const c = () => theme().colors
|
||||
const open = () => view.state.todos.some((t) => t.status === "pending" || t.status === "in_progress")
|
||||
const done = () => view.state.todos.filter((t) => t.status === "completed").length
|
||||
// The item in progress, what is left, then the latest done — at most six lines.
|
||||
const shown = () => {
|
||||
const t = view.state.todos
|
||||
const order = [...t.filter((x) => x.status === "in_progress"), ...t.filter((x) => x.status === "pending"), ...t.filter((x) => x.status === "completed").slice(-2)]
|
||||
return order.slice(0, 6)
|
||||
}
|
||||
return (
|
||||
<Show when={open()}>
|
||||
<box flexDirection="column" paddingLeft={1}>
|
||||
<text fg={c().label}>
|
||||
todo {done()}/{view.state.todos.length}
|
||||
</text>
|
||||
<For each={shown()}>
|
||||
{(t) => (
|
||||
<text wrapMode="none" truncate fg={t.status === "in_progress" ? c().accent : t.status === "completed" ? c().dim : c().text}>
|
||||
{" "}
|
||||
{MARK[t.status]} {t.content}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function Welcome() {
|
||||
return (
|
||||
<box border borderStyle="rounded" borderColor={c().border} flexDirection="column" paddingLeft={1} paddingRight={1} marginTop={1} flexShrink={0}>
|
||||
<text fg={c().title} attributes={TextAttributes.BOLD}>
|
||||
☤ {theme().branding.agentName} <span style={{ fg: c().dim, attributes: 0 }}>{VERSION}</span>
|
||||
{theme().branding.agentName} <span style={{ fg: c().dim, attributes: 0 }}>{VERSION}</span>
|
||||
</text>
|
||||
<text fg={c().text} wrapMode="none" truncate>
|
||||
<span style={{ fg: c().label }}>model </span>
|
||||
|
||||
+2
-5
@@ -17,17 +17,14 @@ export const PLAIN: { toolEmoji: Record<string, string>; icons: { mcp: string; o
|
||||
bash_output: "›",
|
||||
bash_list: "›",
|
||||
bash_kill: "×",
|
||||
todo: "▣",
|
||||
question: "?",
|
||||
plan_write: "▣",
|
||||
plan_update: "▣",
|
||||
ask_user: "?",
|
||||
view_image: "▨",
|
||||
plan_exit: "▸",
|
||||
plan_submit: "▸",
|
||||
task: "◆",
|
||||
tasks: "≡",
|
||||
decisions: "•",
|
||||
websearch: "◎",
|
||||
webfetch: "↓",
|
||||
web_search: "◎",
|
||||
web_fetch: "↓",
|
||||
memory: "◈",
|
||||
|
||||
+12
-17
@@ -1,13 +1,12 @@
|
||||
// Every colour the TUI uses comes from here — no hex literal anywhere else under src/tui/. This
|
||||
// file is the palettes and the loader only, with nothing from OpenTUI, so `lembas run` can use
|
||||
// it without loading the renderer; theme.ts adds what needs OpenTUI.
|
||||
// The default theme, lembas, is dark: cyan on ink, blue and violet beside it; lembas-light is its
|
||||
// light counterpart, and moria and shire are the LLeMbas web UI's own two
|
||||
// (src/lembas/web/static/css/tokens.css). hermes is Hermes Agent's
|
||||
// default ("Classic Hermes — gold", hermes_cli/skin_engine.py and agent/display.py, MIT,
|
||||
// © 2025 Nous Research).
|
||||
// The default theme, moria, is the LLeMbas web UI's own dark one (src/lembas/web/static/css/
|
||||
// tokens.css), shire its light one; lembas is dark, cyan on ink, blue and violet beside it, and
|
||||
// lembas-light its light counterpart. hermes is Hermes Agent's default ("Classic Hermes — gold",
|
||||
// hermes_cli/skin_engine.py and agent/display.py, MIT, © 2025 Nous Research).
|
||||
// A user theme is ~/.config/lembas/themes/<name>.yaml (skins/ is still read) with any subset
|
||||
// of these keys, merged over lembas.
|
||||
// of these keys, merged over moria.
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { parse } from "yaml"
|
||||
@@ -62,7 +61,8 @@ export interface Theme {
|
||||
comment: string
|
||||
}
|
||||
branding: { agentName: string; responseLabel: string; promptSymbol: string; toolPrefix: string }
|
||||
spinner: { waiting: string[]; thinking: string[]; verbs: string[]; frames: string[] }
|
||||
/** The line while the model works: a frame every tick, a verb while it thinks. */
|
||||
spinner: { verbs: string[]; frames: string[] }
|
||||
toolEmoji: Record<string, string>
|
||||
/** The other pictures: an MCP tool, any other tool, a refused call, the microphone, the speaker, a locked server. */
|
||||
icons: { mcp: string; other: string; denied: string; mic: string; speaker: string; locked: string }
|
||||
@@ -111,10 +111,8 @@ export const HERMES: Theme = {
|
||||
type: "#DAA520",
|
||||
comment: "#C0C0C0",
|
||||
},
|
||||
branding: { agentName: "LLeMbas", responseLabel: " ☤ LLeMbas ", promptSymbol: "❯", toolPrefix: "┊" },
|
||||
branding: { agentName: "LLeMbas", responseLabel: " LLeMbas ", promptSymbol: "❯", toolPrefix: "┊" },
|
||||
spinner: {
|
||||
waiting: ["(。◕‿◕。)", "(◕‿◕✿)", "٩(◕‿◕。)۶", "(✿◠‿◠)", "( ˘▽˘)っ", "♪(´ε` )", "(◕ᴗ◕✿)", "ヾ(^∇^)", "(≧◡≦)", "(★ω★)"],
|
||||
thinking: ["(。•́︿•̀。)", "(◔_◔)", "(¬‿¬)", "( •_•)>⌐■-■", "(⌐■_■)", "(´・_・`)", "◉_◉", "(°ロ°)", "٩(๑❛ᴗ❛๑)۶", "(⊙_⊙)", "ಠ_ಠ"],
|
||||
verbs: ["pondering", "contemplating", "musing", "cogitating", "ruminating", "deliberating", "mulling", "reflecting", "reasoning", "analyzing", "synthesizing", "formulating"],
|
||||
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
||||
},
|
||||
@@ -133,17 +131,14 @@ export const HERMES: Theme = {
|
||||
bash_output: "📜",
|
||||
bash_list: "📜",
|
||||
bash_kill: "🛑",
|
||||
todo: "📋",
|
||||
question: "❓",
|
||||
plan_write: "📋",
|
||||
plan_update: "📋",
|
||||
ask_user: "❓",
|
||||
view_image: "📷",
|
||||
plan_exit: "🧭",
|
||||
plan_submit: "🧭",
|
||||
task: "🤖",
|
||||
tasks: "📚",
|
||||
decisions: "📌",
|
||||
websearch: "🔍",
|
||||
webfetch: "🌐",
|
||||
web_search: "🔍",
|
||||
web_fetch: "🌐",
|
||||
memory: "🧠",
|
||||
@@ -172,7 +167,7 @@ export const MONO: Theme = {
|
||||
colors: Object.fromEntries(Object.keys(HERMES.colors).map((k) => [k, k.endsWith("Bg") || k === "panel" || k === "menu" ? "#202020" : k === "selection" || k === "menuCurrent" ? "#404040" : "#d0d0d0"])) as Theme["colors"],
|
||||
}
|
||||
|
||||
/** What every built-in theme but Hermes's own shares: LLeMbas's branding, Hermes' spinner and icons. */
|
||||
/** What every built-in theme but Hermes's own shares: LLeMbas's branding, the spinner and the icons. */
|
||||
const LEMBAS_LOOK = { branding: HERMES.branding, spinner: HERMES.spinner, toolEmoji: HERMES.toolEmoji, icons: HERMES.icons }
|
||||
|
||||
/** lembas — dark, cyan on ink, blue and violet beside it; danger a warm red, success a leaf
|
||||
@@ -371,7 +366,7 @@ export const SHIRE: Theme = {
|
||||
...LEMBAS_LOOK,
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME = LEMBAS
|
||||
export const DEFAULT_THEME = MORIA
|
||||
export const BUILTIN_THEMES: Record<string, Theme> = { lembas: LEMBAS, "lembas-light": LEMBAS_LIGHT, moria: MORIA, shire: SHIRE, hermes: HERMES, mono: MONO }
|
||||
|
||||
/** Where user themes are read from: themes/ first, then the old skins/. */
|
||||
|
||||
+47
-11
@@ -7,16 +7,19 @@ import type { Asker, AskReply, Bus, Event } from "../bus/index.ts"
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import type { Decision, PermissionRequest } from "../permission/evaluate.ts"
|
||||
import type { Message, Usage } from "../provider/types.ts"
|
||||
import type { PlanReply } from "../tool/plan_exit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/question.ts"
|
||||
import type { Todo } from "../tool/todo.ts"
|
||||
import type { Plan } from "../plan/plan.ts"
|
||||
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
||||
import { CONTINUE_PROMPT, STEER_MARK } from "../session/engine.ts"
|
||||
import { splitPrototypes, titleFor, type PrototypeKind } from "../prototype/fence.ts"
|
||||
import { savePrototype } from "../prototype/store.ts"
|
||||
import { CHARS_PER_TOKEN } from "../provider/tokens.ts"
|
||||
|
||||
export type Item =
|
||||
/** `mid`: sent while the agent worked, and given to it at a step boundary. */
|
||||
| { kind: "user"; id: string; text: string; mid?: boolean }
|
||||
| { kind: "assistant"; id: string; text: string; streaming: boolean }
|
||||
/** `prototypes`: the prototype fences in the text that have been saved, by where each starts. */
|
||||
| { kind: "assistant"; id: string; text: string; streaming: boolean; prototypes?: Record<number, SavedPrototype> }
|
||||
| { kind: "reasoning"; id: string; text: string; streaming: boolean; started: number; ms?: number; tokens?: number }
|
||||
| {
|
||||
kind: "tool"
|
||||
@@ -37,6 +40,31 @@ export type Item =
|
||||
|
||||
export type Phase = "idle" | "waiting" | "thinking" | "writing" | "tools"
|
||||
|
||||
/** A prototype the model wrote, saved as a file (prototype/store.ts). */
|
||||
export interface SavedPrototype {
|
||||
kind: PrototypeKind
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Whether a reply might hold a fenced block at all, before it is split. */
|
||||
const mayFence = (text: string) => text.includes("```") || text.includes("~~~")
|
||||
|
||||
/** Every completed prototype fence in a reply, saved the first time it is seen — the model's
|
||||
* output as it is, under the state directory — and noted on the item, so the transcript draws
|
||||
* one line in its place. Nothing without a session to keep it under. */
|
||||
function keepPrototypes(sessionId: string | undefined, it: Extract<Item, { kind: "assistant" }>) {
|
||||
if (!sessionId || !mayFence(it.text)) return
|
||||
for (const seg of splitPrototypes(it.text)) {
|
||||
if (seg.kind !== "prototype" || !seg.closed || it.prototypes?.[seg.start]) continue
|
||||
const title = titleFor(seg.code, seg.info.title)
|
||||
try {
|
||||
const path = savePrototype(sessionId, seg.code, { kind: seg.info.kind, title })
|
||||
;(it.prototypes ??= {})[seg.start] = { kind: seg.info.kind, title, path }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Pending {
|
||||
tool: string
|
||||
args: unknown
|
||||
@@ -67,7 +95,8 @@ export interface ViewState {
|
||||
pending?: Pending
|
||||
question?: PendingQuestion
|
||||
plan?: import("./components/plancard.tsx").PendingPlan
|
||||
todos: Todo[]
|
||||
/** The session's plan (plan_write / plan_update), drawn above the prompt while work is open. */
|
||||
livePlan?: Plan
|
||||
/** Tool call being streamed by the model, for the spinner line. */
|
||||
preparing?: string
|
||||
showReasoning: boolean
|
||||
@@ -83,7 +112,7 @@ let seq = 0
|
||||
const nextId = () => `i${++seq}`
|
||||
|
||||
export function createView(bus: Bus) {
|
||||
const [state, set] = createStore<ViewState>({ items: [], phase: "idle", busySince: 0, showReasoning: false, expandTools: false, todos: [], queued: { texts: [], mode: "steer" } })
|
||||
const [state, set] = createStore<ViewState>({ items: [], phase: "idle", busySince: 0, showReasoning: false, expandTools: false, queued: { texts: [], mode: "steer" } })
|
||||
let stepStart = 0
|
||||
// The task's output tokens (every step, subagents included), and the reply
|
||||
// streaming now, counted as it arrives so the number moves before the step's usage comes in.
|
||||
@@ -116,6 +145,8 @@ export function createView(bus: Bus) {
|
||||
}
|
||||
if (last?.kind === "assistant" && last.streaming) last.text += c.text
|
||||
else s.items.push({ kind: "assistant", id: nextId(), text: c.text, streaming: true })
|
||||
const reply = s.items[s.items.length - 1]!
|
||||
if (reply.kind === "assistant") keepPrototypes(s.sessionId, reply)
|
||||
s.phase = "writing"
|
||||
} else {
|
||||
if (last?.kind === "reasoning" && last.streaming) last.text += c.text
|
||||
@@ -135,6 +166,7 @@ export function createView(bus: Bus) {
|
||||
if ((it.kind === "assistant" || it.kind === "reasoning") && it.streaming) {
|
||||
it.streaming = false
|
||||
if (it.kind === "reasoning") it.ms = Date.now() - it.started
|
||||
else keepPrototypes(s.sessionId, it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +267,8 @@ export function createView(bus: Bus) {
|
||||
}),
|
||||
)
|
||||
break
|
||||
case "todos":
|
||||
set("todos", e.items.map((t) => ({ ...t })))
|
||||
case "plan":
|
||||
set("livePlan", e.plan ? structuredClone(e.plan) : undefined)
|
||||
break
|
||||
case "notice":
|
||||
set(produce((s) => void s.items.push({ kind: "notice", id: nextId(), text: e.message, level: "warn" })))
|
||||
@@ -317,7 +349,7 @@ export function createView(bus: Bus) {
|
||||
(done) => set("question", { req, resolve: done }),
|
||||
() => set("question", undefined),
|
||||
)
|
||||
const planLocal = (req: { path: string; text: string }) =>
|
||||
const planLocal = (req: PlanRequest) =>
|
||||
cardLocal<PlanReply>(
|
||||
(done) => set("plan", { ...req, resolve: done }),
|
||||
() => set("plan", undefined),
|
||||
@@ -393,7 +425,11 @@ export function createView(bus: Bus) {
|
||||
items.push({ kind: "user", id: nextId(), text: m.parts.filter((p) => p.type === "text" && !ATTACHMENT.test(p.text)).map((p) => (p.type === "text" ? p.text : "")).join("\n") })
|
||||
else if (m.role === "assistant") {
|
||||
for (const p of m.parts) {
|
||||
if (p.type === "text" && p.text.trim()) items.push({ kind: "assistant", id: nextId(), text: p.text, streaming: false })
|
||||
if (p.type === "text" && p.text.trim()) {
|
||||
const it: Extract<Item, { kind: "assistant" }> = { kind: "assistant", id: nextId(), text: p.text, streaming: false }
|
||||
keepPrototypes(state.sessionId, it)
|
||||
items.push(it)
|
||||
}
|
||||
else if (p.type === "reasoning" && p.text.trim()) items.push({ kind: "reasoning", id: nextId(), text: p.text, streaming: false, started: 0 })
|
||||
else if (p.type === "tool_call") {
|
||||
let args: unknown
|
||||
@@ -422,7 +458,7 @@ export function createView(bus: Bus) {
|
||||
export interface CardHooks {
|
||||
ask?: (req: Parameters<Asker["ask"]>[0], local: LocalAsk) => Promise<AskReply>
|
||||
question?: (req: QuestionRequest, callId: string | undefined, local: LocalAsk<QuestionReply>) => Promise<QuestionReply>
|
||||
plan?: (req: { path: string; text: string }, callId: string | undefined, local: LocalAsk<PlanReply>) => Promise<PlanReply>
|
||||
plan?: (req: PlanRequest, callId: string | undefined, local: LocalAsk<PlanReply>) => Promise<PlanReply>
|
||||
}
|
||||
|
||||
export type View = ReturnType<typeof createView>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
declare const LEMBAS_VERSION: string | undefined
|
||||
declare const LEMBAS_DEFAULT_INSTANCE: string | undefined
|
||||
|
||||
// Replaced at compile time by scripts/build.ts; read from package.json when run from source.
|
||||
export const VERSION: string =
|
||||
typeof LEMBAS_VERSION === "string" ? LEMBAS_VERSION : (await import("../package.json")).default.version
|
||||
|
||||
/** Reserved for a build that names an instance for its first-run screen. Always "" in the
|
||||
* builds this repository makes, and nothing reads it yet. */
|
||||
export const DEFAULT_INSTANCE: string = typeof LEMBAS_DEFAULT_INSTANCE === "string" ? LEMBAS_DEFAULT_INSTANCE : ""
|
||||
Reference in new issue
Block a user