first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+538
View File
@@ -0,0 +1,538 @@
// This file implements slash-commands (US-029, #45): typed "/name" shortcuts a
// user invokes in the REPL. There are two sources, resolved with a fixed
// priority:
//
// - Built-in commands are registered at compile time via RegisterBuiltin
// (from init() in the fork's own code). They are always available.
// - User commands are declarative markdown templates loaded from a directory
// (mirrors the .../commands/*.md convention): the file name is the command
// name and the body is a prompt template that may reference $ARGUMENTS.
//
// Conflict rule: same-name commands resolve by priority tier (built-in >
// project > global > package > settings > CLI); the higher tier wins and the
// loser is reported via Shadowed. Built-ins are load-bearing and always win.
// Within a tier, the last-added command overrides earlier ones (a re-load).
//
// There is deliberately no standalone plugin mechanism: a fork adds built-ins
// via init() registration, and external extensions go through MCP (deferred).
package runtime
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// SlashCommandSource identifies where a command came from, used for the
// conflict/priority rule and for display.
type SlashCommandSource int
const (
// SourceBuiltin is a compile-time registered command (highest priority).
SourceBuiltin SlashCommandSource = iota
// SourceUser is a declarative markdown command template loaded from disk
// (e.g. ~/.pigo/commands/*.md).
SourceUser
// SourceSkill is a skill loaded from ~/.agents/skills and surfaced as a
// /skill-name command. It behaves like SourceUser for the built-in-wins
// conflict rule; the finer tag is for display only (e.g. /status).
SourceSkill
// SourcePlugin is a command declared by a loaded plugin. It behaves like
// SourceUser for the built-in-wins conflict rule; the finer tag is for
// display only.
SourcePlugin
)
func (s SlashCommandSource) String() string {
switch s {
case SourceBuiltin:
return "builtin"
case SourceSkill:
return "skill"
case SourcePlugin:
return "plugin"
default:
return "user"
}
}
// Tier is the priority tier of a command, used to resolve same-name conflicts
// across sources (mirrors pi prompt-templates discovery priority). Higher tiers
// win; the loser is recorded in Shadowed. Within the same tier the last-added
// command wins (a re-load overrides). Skills and plugins are treated as
// Global-tier for priority - their finer Source label is for display only.
type Tier int
const (
// Tier values are ordered lowest-to-highest priority: in a same-name
// conflict the higher Tier value wins, so TierBuiltin always wins and
// TierCLI always loses. Declared ascending so the natural > comparison
// matches "higher priority wins".
TierCLI Tier = iota
// TierSettings is a prompt template referenced by the config.toml prompts array.
TierSettings
// TierPackage is a prompt template discovered from an installed package
// source (distinct from one copied into the global dir).
TierPackage
// TierGlobal is a global user prompt template (e.g. ~/.pigo/prompts or the
// legacy ~/.pigo/commands); also the tier used for skills and plugins.
TierGlobal
// TierProject is a project-local prompt template (e.g. .pigo/prompts).
TierProject
// TierBuiltin is a compile-time or instance built-in command (highest).
TierBuiltin
)
func (t Tier) String() string {
switch t {
case TierBuiltin:
return "builtin"
case TierProject:
return "project"
case TierGlobal:
return "global"
case TierPackage:
return "package"
case TierSettings:
return "settings"
case TierCLI:
return "cli"
default:
return "unknown"
}
}
// ShadowedEntry records a command that lost a same-name conflict to a higher-
// tier command, for diagnostics. It carries the loser's name, tier, and source
// label so /help and the startup warning can say which source was shadowed.
type ShadowedEntry struct {
Name string
Tier Tier
Source SlashCommandSource
}
// String renders a shadowed entry as "name (tier)" for log lines.
func (e ShadowedEntry) String() string { return fmt.Sprintf("%s (%s)", e.Name, e.Tier) }
// SlashCommand is a resolved command: its name (without the leading "/"), a
// short description for the command palette, and its source. A command is one
// of three kinds, distinguished by which callback is set:
//
// - A prompt command sets Expand: it turns the invocation arguments into the
// prompt text fed to the agent (the original slash-command behavior).
// - An action command sets Action instead: it performs a side effect (e.g.
// switching the runtime model) and returns a status line to show the user,
// rather than producing a prompt. No agent run is started.
// - A hybrid command sets Run: it performs a side effect AND may return prompt
// text to run — used by plugin commands, which RPC their plugin, surface the
// returned notifications, then inject the returned prompt as the next turn.
//
// Exactly one of Expand/Action/Run should be set. Precedence when more than one
// is set: Action wins over Run, which wins over Expand. This split is what lets
// a control command like "/model" change runtime state — the old design could
// only emit prompt text.
type SlashCommand struct {
Name string
Description string
// ArgumentHint is an optional frontmatter hint shown before the description
// in autocomplete (e.g. "<PR-URL>"). Convention: <angle> for required args,
// [square] for optional. Empty when not set; display-only, not enforced.
ArgumentHint string
Source SlashCommandSource
// Tier is the priority tier used to resolve same-name conflicts across
// sources (built-in > project > global > package > settings > CLI). It is
// set by the AddX method matching the command's source; callers should not
// set it directly.
Tier Tier
// Expand maps the argument string (everything after "/name ") to the prompt
// text the command produces. For a built-in it may be arbitrary Go; for a
// user template it substitutes $ARGUMENTS into the markdown body. Nil for an
// action command.
Expand func(args string) string
// Action performs a side effect for the invocation and returns a status
// message to display (may be empty). Set instead of Expand for a control
// command like "/model". Because it is an arbitrary Go closure it can capture
// and mutate live runtime state, which Expand (a pure prompt producer)
// cannot. Nil for a prompt command.
Action func(args string) string
// Run is the hybrid of Action and Expand: it performs a side effect AND may
// produce prompt text to run as the next agent turn. It returns
// (message, prompt): message is shown to the user immediately (like an
// Action's status, e.g. plugin notifications), and prompt, when non-empty, is
// run as a normal turn (like Expand's output). This is what a plugin command
// needs — it RPCs its plugin (side effect), surfaces the returned
// notifications (message), then injects the returned prompt (prompt). Set
// instead of Expand/Action for such a command; nil otherwise. When Run is set
// it takes precedence over Expand (but Action still wins over Run).
Run func(args string) (message, prompt string)
}
// SlashKind classifies how a resolved invocation should be handled by the
// caller: run its prompt through the agent, or treat it as a completed action.
type SlashKind int
const (
// SlashPrompt means the outcome carries prompt text to run (or, when not a
// command at all, the verbatim input).
SlashPrompt SlashKind = iota
// SlashAction means an action command already ran; the outcome carries only
// a status Message and no agent run should start.
SlashAction
)
// SlashOutcome is the structured result of resolving one input line. Handled is
// false when the input was not a slash command (Prompt holds the verbatim input
// to run). When Handled is true, Kind says whether Prompt should be run
// (SlashPrompt) or an action already ran and Message should be shown without
// starting a run (SlashAction).
//
// A hybrid (Run) command resolves to Kind SlashPrompt with BOTH fields set: its
// side effect already ran, Message carries the text to show the user first
// (e.g. plugin notifications), and Prompt, when non-empty, is the turn to run
// after. The caller shows Message (if any) then runs Prompt (if non-empty).
type SlashOutcome struct {
Handled bool
Kind SlashKind
Prompt string
Message string
}
// builtinCommands holds compile-time registered commands, keyed by name. It is
// populated by RegisterBuiltin from init() and read when building a registry.
//
// Concurrency contract: this global is written only by RegisterBuiltin, which
// must be called from init() (single-threaded, before main), and read only
// afterwards by NewSlashRegistry. It carries no lock because that init-only
// discipline means there is never a concurrent write; do not call
// RegisterBuiltin after startup.
var builtinCommands = map[string]SlashCommand{}
// RegisterBuiltin registers a built-in slash command at compile time. It is
// intended to be called from init(); a duplicate name panics, since two
// built-ins claiming the same name is a programming error in the fork.
func RegisterBuiltin(cmd SlashCommand) {
if cmd.Name == "" {
panic("agent: RegisterBuiltin with empty name")
}
if _, exists := builtinCommands[cmd.Name]; exists {
panic(fmt.Sprintf("agent: duplicate built-in slash command %q", cmd.Name))
}
cmd.Source = SourceBuiltin
cmd.Tier = TierBuiltin
builtinCommands[cmd.Name] = cmd
}
// SlashRegistry resolves "/name" invocations against built-in and user
// commands, applying the built-in-wins priority rule.
type SlashRegistry struct {
commands map[string]SlashCommand
// shadowed records commands that lost a same-name conflict to a higher-tier
// command, with their tier and source for diagnostics. Same-tier overrides
// (last-write-wins) are not recorded.
shadowed []ShadowedEntry
}
// NewSlashRegistry builds a registry seeded with all registered built-ins.
func NewSlashRegistry() *SlashRegistry {
r := &SlashRegistry{commands: make(map[string]SlashCommand, len(builtinCommands))}
for name, cmd := range builtinCommands {
r.commands[name] = cmd
}
return r
}
// AddBuiltin installs a built-in command directly on this registry instance,
// bypassing the compile-time global. It exists for action commands whose
// closure must capture live, per-run state (e.g. a model controller created in
// main) — such state cannot be reached from an init()-time RegisterBuiltin. The
// command is marked SourceBuiltin so it wins over a same-named user command,
// exactly like a globally registered built-in. A duplicate name panics, since
// two built-ins claiming one name is a programming error.
func (r *SlashRegistry) AddBuiltin(cmd SlashCommand) {
if cmd.Name == "" {
panic("agent: AddBuiltin with empty name")
}
if existing, ok := r.commands[cmd.Name]; ok && existing.Source == SourceBuiltin {
panic(fmt.Sprintf("agent: duplicate built-in slash command %q", cmd.Name))
}
cmd.Source = SourceBuiltin
cmd.Tier = TierBuiltin
r.add(cmd)
}
// AddUser installs a user command (TierGlobal), e.g. a prompt template from
// ~/.pigo/prompts or the legacy ~/.pigo/commands. A same-named built-in or
// project-tier command wins; same-tier (global) adds override silently.
func (r *SlashRegistry) AddUser(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierGlobal
r.add(cmd)
}
// AddSkill installs a skill command (loaded from ~/.agents/skills) at TierGlobal.
// It follows the same tier rule as AddUser - a built-in or project-tier command
// wins - only the source tag differs, so /status can report skills separately.
func (r *SlashRegistry) AddSkill(cmd SlashCommand) {
cmd.Source = SourceSkill
cmd.Tier = TierGlobal
r.add(cmd)
}
// AddPlugin installs a plugin-declared command at TierGlobal, mirroring AddUser
// with a SourcePlugin tag for display.
func (r *SlashRegistry) AddPlugin(cmd SlashCommand) {
cmd.Source = SourcePlugin
cmd.Tier = TierGlobal
r.add(cmd)
}
// Shadowed returns the commands that lost a same-name conflict to a higher-tier
// command, with their tier and source for diagnostics. Same-tier overrides
// (last-write-wins) are not recorded here.
func (r *SlashRegistry) Shadowed() []ShadowedEntry { return r.shadowed }
// AddProject installs a project-local prompt template (TierProject), which
// overrides a same-named global/package/settings/CLI template but loses to a
// built-in.
func (r *SlashRegistry) AddProject(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierProject
r.add(cmd)
}
// AddPackage installs a package-discovered prompt template (TierPackage).
func (r *SlashRegistry) AddPackage(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierPackage
r.add(cmd)
}
// AddSettings installs a prompt template referenced by config.toml (TierSettings).
func (r *SlashRegistry) AddSettings(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierSettings
r.add(cmd)
}
// AddCLI installs a prompt template referenced by --prompt-template (TierCLI,
// the lowest priority).
func (r *SlashRegistry) AddCLI(cmd SlashCommand) {
cmd.Source = SourceUser
cmd.Tier = TierCLI
r.add(cmd)
}
// add installs cmd with tier-based conflict resolution. If a same-named command
// already exists, the higher tier wins and the loser is appended to shadowed;
// within the same tier the new command replaces the old (last-write-wins, no
// shadow entry). A built-in always wins because TierBuiltin is highest.
func (r *SlashRegistry) add(cmd SlashCommand) {
existing, ok := r.commands[cmd.Name]
if !ok {
r.commands[cmd.Name] = cmd
return
}
switch {
case existing.Tier > cmd.Tier:
// New command is lower tier: it loses and is shadowed.
r.shadowed = append(r.shadowed, ShadowedEntry{Name: cmd.Name, Tier: cmd.Tier, Source: cmd.Source})
case existing.Tier < cmd.Tier:
// New command is higher tier: it wins; the old one is shadowed.
r.shadowed = append(r.shadowed, ShadowedEntry{Name: existing.Name, Tier: existing.Tier, Source: existing.Source})
r.commands[cmd.Name] = cmd
default:
// Same tier: last-write-wins (a re-load), no shadow entry.
r.commands[cmd.Name] = cmd
}
}
// Lookup returns the command bound to name (without the leading "/").
func (r *SlashRegistry) Lookup(name string) (SlashCommand, bool) {
cmd, ok := r.commands[name]
return cmd, ok
}
// List returns all commands sorted by name.
func (r *SlashRegistry) List() []SlashCommand {
out := make([]SlashCommand, 0, len(r.commands))
for _, c := range r.commands {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Resolve parses a raw input line and, if it is a slash-command invocation,
// expands it to the prompt text the agent should run. It returns (prompt, true)
// when input begins with "/" and names a known PROMPT command; (input, false)
// when the input is not a slash command (the caller runs it verbatim); and an
// error when input is a "/name" for an unknown command.
//
// This is the legacy string API, kept for callers that only handle prompt
// commands. It reports an action command as handled with an empty prompt (the
// action does NOT run here) — callers that want action commands to execute must
// use ResolveOutcome instead.
func (r *SlashRegistry) Resolve(input string) (prompt string, handled bool, err error) {
out, err := r.ResolveOutcome(input)
if err != nil {
return "", false, err
}
return out.Prompt, out.Handled, nil
}
// ResolveOutcome parses a raw input line into a structured SlashOutcome. For a
// non-command it returns {Handled:false, Prompt:input}. For a known prompt
// command it returns {Handled:true, Kind:SlashPrompt, Prompt:<expanded>}. For a
// known action command it RUNS the action and returns {Handled:true,
// Kind:SlashAction, Message:<status>} — no prompt to run. For a known hybrid
// (Run) command it RUNS the side effect and returns {Handled:true,
// Kind:SlashPrompt, Message:<status>, Prompt:<text>} — the caller shows Message
// then runs Prompt when non-empty. An unknown "/name" yields an error.
func (r *SlashRegistry) ResolveOutcome(input string) (SlashOutcome, error) {
trimmed := strings.TrimLeft(input, " \t")
if !strings.HasPrefix(trimmed, "/") {
return SlashOutcome{Handled: false, Kind: SlashPrompt, Prompt: input}, nil
}
rest := trimmed[1:]
name := rest
args := ""
if i := strings.IndexAny(rest, " \t"); i >= 0 {
name = rest[:i]
args = strings.TrimSpace(rest[i+1:])
}
cmd, ok := r.commands[name]
if !ok {
return SlashOutcome{}, fmt.Errorf("unknown command %q", "/"+name)
}
if cmd.Action != nil {
return SlashOutcome{Handled: true, Kind: SlashAction, Message: cmd.Action(args)}, nil
}
if cmd.Run != nil {
// A hybrid command runs its side effect now and may yield prompt text.
// The outcome is a prompt (SlashPrompt) that also carries a Message to
// surface first; the caller shows Message then runs Prompt if non-empty.
message, prompt := cmd.Run(args)
return SlashOutcome{Handled: true, Kind: SlashPrompt, Message: message, Prompt: prompt}, nil
}
return SlashOutcome{Handled: true, Kind: SlashPrompt, Prompt: cmd.Expand(args)}, nil
}
// firstNonEmptyLine returns the first line of s whose trimmed form is non-empty,
// itself trimmed. It is the description fallback for templates whose frontmatter
// omits a description (mirrors pi: "If missing, the first non-empty line is used").
func firstNonEmptyLine(s string) string {
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); t != "" {
return t
}
}
return ""
}
// LoadPromptFile loads a single prompt-template file. The command name is the
// filename without its extension (e.g. /x/review.md -> "review"). It is the
// single-file counterpart of LoadUserCommandsDir, used for settings/CLI paths
// that point at one file rather than a directory.
func LoadPromptFile(path string) (SlashCommand, error) {
content, err := os.ReadFile(path)
if err != nil {
return SlashCommand{}, fmt.Errorf("read prompt %s: %w", path, err)
}
base := filepath.Base(path)
name := strings.TrimSuffix(base, filepath.Ext(base))
return ParseUserCommand(name, content)
}
// LoadUserCommandsDir loads declarative markdown command templates from dir
// (non-recursively). Each "*.md" file defines a command named after the file
// (without extension). The file may carry an optional YAML frontmatter block
// with a "description" (mirrors skills); the remaining body is the prompt template,
// expanded via ExpandTemplate at invoke time. A missing directory yields no
// commands and no error.
func LoadUserCommandsDir(dir string) ([]SlashCommand, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read commands dir %s: %w", dir, err)
}
var cmds []SlashCommand
for _, e := range entries {
if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".md") {
continue
}
path := filepath.Join(dir, e.Name())
content, readErr := os.ReadFile(path)
if readErr != nil {
return nil, fmt.Errorf("read command %s: %w", path, readErr)
}
name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
cmd, parseErr := ParseUserCommand(name, content)
if parseErr != nil {
return nil, parseErr
}
cmds = append(cmds, cmd)
}
sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name })
return cmds, nil
}
// ParseUserCommand parses a declarative command template. An optional YAML
// frontmatter block supplies a description; the body is the prompt template,
// expanded at invoke time via ExpandTemplate (positional $N, $@/$ARGUMENTS,
// ${1:-default}, ${@:N}). If arg tokenization fails (e.g. an unterminated
// quote) the raw arg string is used as $ARGUMENTS so the invocation still works.
func ParseUserCommand(name string, content []byte) (SlashCommand, error) {
body := string(content)
description := ""
hint := ""
// Reuse the skills frontmatter splitter when a fence is present; otherwise
// treat the whole file as the template body.
if strings.HasPrefix(strings.TrimLeft(strings.TrimPrefix(body, "\ufeff"), "\r\n"), "---") {
fm, rest, splitErr := splitFrontmatter(content)
if splitErr != nil {
return SlashCommand{}, fmt.Errorf("command %s: %w", name, splitErr)
}
var meta struct {
Description string `yaml:"description"`
Name string `yaml:"name"`
ArgumentHint string `yaml:"argument-hint"`
}
if err := yaml.Unmarshal(fm, &meta); err != nil {
return SlashCommand{}, fmt.Errorf("command %s: parse frontmatter: %w", name, err)
}
description = meta.Description
hint = meta.ArgumentHint
if meta.Name != "" {
name = meta.Name
}
body = string(rest)
}
// When the frontmatter omits a description, fall back to the first non-empty
// line of the body (\u5bf9\u6807 pi: "If missing, the first non-empty line is used").
if description == "" {
description = firstNonEmptyLine(body)
}
template := strings.TrimSpace(body)
return SlashCommand{
Name: name,
Description: description,
ArgumentHint: hint,
Source: SourceUser,
Expand: func(args string) string {
tokens, err := SplitArgs(args)
if err != nil {
// Split failure (e.g. an unterminated quote): treat the raw arg
// string as a single $ARGUMENTS rather than feeding a malformed
// arg list to the engine, so a bad invocation stays usable.
tokens = []string{args}
}
return ExpandTemplate(template, tokens)
},
}, nil
}