From a32b802578acbe654280b6f0fa1a25fcbdcaf3a2 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 20 Sep 2026 10:56:53 +0100 Subject: [PATCH 1/2] FIX: route live-state questions independently of punctuation --- README.md | 2 ++ internal/app/app.go | 3 -- internal/app/app_test.go | 65 +++++++++++++++++++++++++++++++++++- internal/app/prompts.go | 8 ++--- internal/systemone/client.go | 6 ++-- 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bb597b6..c437d7e 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,8 @@ Risk is model-generated guidance, not a security boundary. Read every proposed o If `system_one_key`, `system_one_api`, and `system_one_model` are configured, CLAI uses that System One-compatible API for typed intent routing and command-risk auditing. The main LLM still generates commands and explanations, but System One decides whether a request is a command task, question, or history-clear request, and independently audits proposed command risk before `risk_appetite` can auto-run it. A higher System One risk label overrides the LLM risk label; low-confidence risk audits force a confirmation prompt. +Requests are interpreted by meaning rather than question marks. Questions needing live data, such as `what is the time?` or `how much disk space is free?`, should produce a command to obtain the answer and follow the usual risk and confirmation rules. General knowledge and how-to questions can be answered directly without execution. With `share_command_results=true`, CLAI also interprets the resulting command output. These decisions use System One when configured, or the main LLM otherwise. + ## Providers CLAI selects its native adapter from the configured `api` URL: diff --git a/internal/app/app.go b/internal/app/app.go index a647f1e..aa8281e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -344,9 +344,6 @@ func (a *Application) routeIntent(ctx context.Context, query, requestedKind stri return "", fmt.Errorf("system one returned unknown intent %q", decision.Intent) } } - if isQuestion(query) { - return "question", nil - } return "execute", nil } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 621e910..0968243 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -243,7 +243,7 @@ func TestProcessQuestionDoesNotRunCommand(t *testing.T) { Runner: commandRunner, UI: ui.New(strings.NewReader(""), &out, &out, false), } - if err := application.process(context.Background(), "how much is 3 times pi?", ""); err != nil { + if err := application.process(context.Background(), "how much is 3 times pi?", "question"); err != nil { t.Fatal(err) } if len(commandRunner.calls) != 0 { @@ -261,6 +261,69 @@ func TestProcessQuestionDoesNotRunCommand(t *testing.T) { } } +func TestLiveStateQuestionsRunAndInterpretRegardlessOfPunctuation(t *testing.T) { + for _, routed := range []bool{false, true} { + for _, query := range []string{"what is the time", "what is the time?"} { + for _, appetite := range []int{0, 1} { + t.Run(fmt.Sprintf("systemone=%v/%s/appetite=%d", routed, query, appetite), func(t *testing.T) { + var out bytes.Buffer + client := &fakeClient{responses: []provider.Response{ + {Text: `{"cmd":"date","info":"shows the current system time","risk":"none","variables":[]}`}, + {Text: `{"cmd":"","info":"The current system time is 10:52:15 BST.","risk":"none","variables":[]}`}, + }} + runner := &fakeRunner{results: []model.CommandResult{{Command: "date", Stdout: "Sun 20 Sep 10:52:15 BST 2026\n"}}} + application := &Application{ + Config: &config.Config{RiskAppetite: appetite, ShareCommandResults: true, ResultLines: 20, MaxHistoryTurns: 10}, + History: &history.Store{Path: filepath.Join(t.TempDir(), "history.json")}, + Tools: testTools(t), Client: client, Runner: runner, + UI: ui.New(strings.NewReader("y\n"), &out, &out, false), + } + if routed { + application.SystemOne = &fakeSystemOne{ + intent: systemone.IntentDecision{Intent: systemone.IntentExecute, Confidence: 0.95}, + risk: systemone.RiskDecision{Risk: "none", Confidence: 0.95}, + } + } + if err := application.process(context.Background(), query, ""); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 || runner.calls[0] != "date" { + t.Fatalf("commands: %v", runner.calls) + } + if len(client.requests) != 2 || !strings.Contains(out.String(), "The current system time is 10:52:15 BST.") { + t.Fatalf("result not interpreted: %s", out.String()) + } + if strings.Contains(out.String(), "execute command?") != (appetite == 0) { + t.Fatalf("confirmation policy changed: %s", out.String()) + } + }) + } + } + } +} + +func TestGeneralQuestionCanAnswerWithoutExecuting(t *testing.T) { + for _, query := range []string{"how do I list all files", "how do I list all files?"} { + t.Run(query, func(t *testing.T) { + var out bytes.Buffer + client := &fakeClient{responses: []provider.Response{{Text: `{"cmd":"","info":"Use ls -a to list all files.","risk":"none","variables":[]}`}}} + runner := &fakeRunner{} + application := &Application{ + Config: &config.Config{RiskAppetite: 1, MaxHistoryTurns: 10}, + History: &history.Store{Path: filepath.Join(t.TempDir(), "history.json")}, + Tools: testTools(t), Client: client, Runner: runner, + UI: ui.New(strings.NewReader(""), &out, &out, false), + } + if err := application.process(context.Background(), query, ""); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 0 || !strings.Contains(out.String(), "Use ls -a") { + t.Fatalf("unexpected result: %s", out.String()) + } + }) + } +} + func TestSystemOneRoutesQuestionWithoutQuestionMark(t *testing.T) { var out bytes.Buffer client := &fakeClient{responses: []provider.Response{{Text: `{"cmd":"rm -rf /tmp/question-mode","info":"approximately 9.4248","risk":"danger zone","variables":[]}`, FinishReason: "stop"}}} diff --git a/internal/app/prompts.go b/internal/app/prompts.go index 246d62d..81570bd 100644 --- a/internal/app/prompts.go +++ b/internal/app/prompts.go @@ -26,7 +26,7 @@ func CurrentVersion() string { return strings.TrimSpace(sourceVersion) } -const defaultExecQuery = "Return only a single compact JSON object containing 'cmd', 'info', 'risk' and 'variables' fields. 'cmd' must contain one or more shell commands that perform the task, or be empty only as a last resort. 'info' must be a single-line explanation. 'risk' must be exactly 'none', 'reversible change', or 'danger zone'. Use 'none' only for read-only inspection. Use 'reversible change' for changes that are normally undoable. Use 'danger zone' for deletion, overwrite, reset, force, or hard-to-reverse changes. 'variables' must be an array. Represent missing user values as {{variable_name}} in cmd and info and include matching objects with name and prompt. Do not quote placeholders in cmd; CLAI shell-escapes substitutions." +const defaultExecQuery = "Return only a single compact JSON object containing 'cmd', 'info', 'risk' and 'variables' fields. Interpret the user's intent regardless of question marks. For requests needing current system or external state (such as the time, disk space, or git status), provide a command to obtain it; do not invent live facts or merely describe the command. For general knowledge, explanations, or how-to questions that need no live data, answer directly in info with empty cmd and variables and risk 'none'. Otherwise cmd must contain shell commands that perform the task. 'info' must be a single-line answer or command explanation. 'risk' must be exactly 'none', 'reversible change', or 'danger zone'. Use 'none' only for read-only inspection. Use 'reversible change' for changes that are normally undoable. Use 'danger zone' for deletion, overwrite, reset, force, or hard-to-reverse changes. 'variables' must be an array. Represent missing user values as {{variable_name}} in cmd and info and include matching objects with name and prompt. Do not quote placeholders in cmd; CLAI shell-escapes substitutions." const defaultQuestionQuery = "Return only a single compact JSON object with cmd, info, risk and variables. For questions, cmd must be empty, risk must be 'none', variables must be empty, and info must be a concise terminal-related answer." @@ -99,6 +99,8 @@ func templateMessages(kind, system string) []model.Message { // Result interpretation uses the real request and bounded command output, // without command-generation examples that could invite another action. default: + add("what is the time?", `{ "cmd": "date", "info": "shows the current system date and time", "risk": "none", "variables": [] }`) + add("how do I list all files", `{ "cmd": "", "info": "Use ls -a to list all files, including hidden files.", "risk": "none", "variables": [] }`) add("list all files", `{ "cmd": "ls -a", "info": "lists all files, including hidden ones", "risk": "none", "variables": [] }`) add("remove the hello world folder", `{ "cmd": "rm -r \"hello world\"", "info": "recursively removes the folder and its contents", "risk": "danger zone", "variables": [] }`) add("checkout a new branch", `{ "cmd": "git checkout -b {{branch_name}}", "info": "creates and switches to {{branch_name}}", "risk": "reversible change", "variables": [{"name":"branch_name","prompt":"new branch name"}] }`) @@ -106,10 +108,6 @@ func templateMessages(kind, system string) []model.Message { return messages } -func isQuestion(query string) bool { - return strings.Contains(query, "?") -} - func isClearRequest(query string) bool { query = strings.TrimSpace(strings.ToLower(query)) query = strings.TrimRight(query, ".!?;:") diff --git a/internal/systemone/client.go b/internal/systemone/client.go index 8a23742..ed731c7 100644 --- a/internal/systemone/client.go +++ b/internal/systemone/client.go @@ -126,10 +126,10 @@ func (c *HTTPClient) RouteIntent(ctx context.Context, input IntentRequest) (Inte Questions: map[string]question{ "intent": { "type": "choice", - "instructions": "Which CLAI workflow should handle this user request?", + "instructions": "Which CLAI workflow should handle this user request? Decide from the information or action needed, not punctuation. Questions needing live system or external state require execution to obtain the answer.", "criteria": map[string]string{ - IntentExecute: "The user wants CLAI to propose a shell command or perform a terminal task.", - IntentQuestion: "The user asks for an explanation or answer and no shell command should be proposed.", + IntentExecute: "The user wants a terminal task performed or an answer requiring current system or external state, such as the current time, free disk space, running processes, or git status. This includes requests phrased as questions.", + IntentQuestion: "The user wants general knowledge, an explanation, or how-to instructions that can be answered without inspecting live system or external state. No shell command needs to run.", IntentClearHistory: "The user wants to clear, reset, forget, or flush CLAI conversation history.", }, }, From dc3dd74e412579d45c220c41f9b79087642599cc Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 20 Sep 2026 11:03:24 +0100 Subject: [PATCH 2/2] FIX: expose explicit answer-only route for question_query --- README.md | 3 ++- cmd/clai/main.go | 1 + internal/app/app.go | 12 +++++++-- internal/app/app_test.go | 54 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c437d7e..ff6b1af 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ clai "how do I show hidden files?" | --- | --- | | `clai setup` | Run the configuration wizard. | | `clai --setup` | Compatibility alias for `setup`. | +| `clai --question ` | Answer-only mode using `question_query`, with or without System One; proposed shell commands are not executed. | | `clai --show-history` | Render persisted conversation history. | | `clai --show-history --verbose` | Include full stored command stdout and stderr. | | `clai --clear-history` | Remove persisted conversation history. | @@ -347,7 +348,7 @@ CLAI creates `~/.config/clai.cfg` on first use. It uses the established CLAI `ke | `confirm_dangerous_commands` | `true` | Require a second confirmation for danger-zone commands. | | `risk_appetite` | `0` | Automatic execution policy from `0` through `2`; invalid values fall back to `0`. | | `exec_query` | empty | Replace the built-in command-generation guidance when set. | -| `question_query` | empty | Replace the built-in question-mode guidance when set. | +| `question_query` | empty | Replace answer-only guidance for `clai --question ` or requests routed as questions by System One. Ordinary requests without System One use `exec_query`, regardless of punctuation. | | `error_query` | empty | Replace the built-in error-recovery guidance when set. | Re-run `clai setup` to change the credential, endpoint, model, or risk appetite. Edit the file directly for the remaining settings. diff --git a/cmd/clai/main.go b/cmd/clai/main.go index 500cd4d..881e999 100644 --- a/cmd/clai/main.go +++ b/cmd/clai/main.go @@ -25,6 +25,7 @@ func newRootCommand(ctx context.Context) *cobra.Command { return &cobra.Command{ Use: "clai [request...]", Short: "AI-powered terminal assistant", + Long: "AI-powered terminal assistant. Use clai --question for an explicit answer-only request using question_query guidance.", Version: app.CurrentVersion(), DisableFlagParsing: true, SilenceErrors: true, diff --git a/internal/app/app.go b/internal/app/app.go index aa8281e..c2f1d2b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -78,6 +78,14 @@ func (a *Application) Run(ctx context.Context, args []string) error { if handled, err := a.handleBuiltIn(ctx, args); handled { return err } + requestedKind := "" + if len(args) > 0 && args[0] == "--question" { + requestedKind = "question" + args = args[1:] + if strings.TrimSpace(strings.Join(args, " ")) == "" { + return fmt.Errorf("usage: clai --question ") + } + } if a.Config.Key == "" { if err := a.setup(); err != nil { return err @@ -85,10 +93,10 @@ func (a *Application) Run(ctx context.Context, args []string) error { } query := strings.TrimSpace(strings.Join(args, " ")) if query != "" { - if isClearRequest(query) { + if requestedKind == "" && isClearRequest(query) { return a.clearHistory() } - return a.process(ctx, query, "") + return a.process(ctx, query, requestedKind) } if err := a.ensureToolsLoaded(ctx); err != nil { return err diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0968243..b29d6be 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -261,6 +261,60 @@ func TestProcessQuestionDoesNotRunCommand(t *testing.T) { } } +func TestExplicitQuestionUsesConfiguredGuidance(t *testing.T) { + for _, systemOne := range []bool{false, true} { + for _, query := range []string{"how do I list files", "clear history"} { + t.Run(fmt.Sprintf("systemone=%v/%s", systemOne, query), func(t *testing.T) { + var out bytes.Buffer + client := &fakeClient{responses: []provider.Response{{Text: `{"cmd":"rm -rf unwanted","info":"An explanation.","risk":"danger zone","variables":[]}`}}} + runner := &fakeRunner{} + router := &fakeSystemOne{} + store := &history.Store{Path: filepath.Join(t.TempDir(), "history.json")} + store.AppendText("user", "prior conversation") + application := &Application{ + Config: &config.Config{Key: "test", QuestionQuery: "Custom answer-only guidance", ExecQuery: "Custom execution guidance", RiskAppetite: 2, MaxHistoryTurns: 10}, + History: store, Tools: testTools(t), Client: client, Runner: runner, + UI: ui.New(strings.NewReader(""), &out, &out, false), + } + if systemOne { + application.SystemOne = router + } + if err := application.Run(context.Background(), []string{"--question", query}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 0 || len(router.intentRequests) != 0 || len(router.riskRequests) != 0 { + t.Fatal("explicit question invoked routing or execution") + } + found := false + for _, message := range client.requests[0].Messages { + found = found || strings.Contains(message.ContentText(), "Custom answer-only guidance") + if strings.Contains(message.ContentText(), "Custom execution guidance") { + t.Fatal("used execution guidance") + } + } + if !found { + t.Fatal("question_query was not applied") + } + if store.Messages[0].ContentText() != "prior conversation" { + t.Fatal("answer-only request cleared history") + } + if !strings.Contains(out.String(), "An explanation.") { + t.Fatalf("missing answer: %s", out.String()) + } + }) + } + } +} + +func TestExplicitQuestionRequiresText(t *testing.T) { + for _, args := range [][]string{{"--question"}, {"--question", " "}} { + application := &Application{} + if err := application.Run(context.Background(), args); err == nil || !strings.Contains(err.Error(), "usage:") { + t.Fatalf("expected usage error, got %v", err) + } + } +} + func TestLiveStateQuestionsRunAndInterpretRegardlessOfPunctuation(t *testing.T) { for _, routed := range []bool{false, true} { for _, query := range []string{"what is the time", "what is the time?"} {