diff --git a/cmd/kata/list.go b/cmd/kata/list.go index 2008887b..31ff03f3 100644 --- a/cmd/kata/list.go +++ b/cmd/kata/list.go @@ -137,6 +137,7 @@ func newListCmd() *cobra.Command { Status string `json:"status"` Owner *string `json:"owner"` Priority *int64 `json:"priority"` + Revision int64 `json:"revision"` Labels []string `json:"labels"` Blocked bool `json:"blocked"` Parent *struct { @@ -164,6 +165,7 @@ func newListCmd() *cobra.Command { agentOptionalRowField("owner", i.Owner), agentRowListField("labels", i.Labels), agentRowField("title", i.Title), + agentRowField("revision", fmt.Sprint(i.Revision)), ); err != nil { return err } diff --git a/cmd/kata/list_test.go b/cmd/kata/list_test.go index d9caa3a8..846473b9 100644 --- a/cmd/kata/list_test.go +++ b/cmd/kata/list_test.go @@ -391,6 +391,7 @@ func TestList_AgentOutputRowsOmitAbsentOwner(t *testing.T) { assert.Contains(t, out, "OK list count=1\n") assert.Contains(t, out, `title="unowned task"`) + assert.Contains(t, out, "revision=1") assert.NotContains(t, out, "owner=") } diff --git a/cmd/kata/search.go b/cmd/kata/search.go index 3b9ad532..91962c04 100644 --- a/cmd/kata/search.go +++ b/cmd/kata/search.go @@ -7,9 +7,12 @@ import ( "net/http" "net/url" "strings" + "unicode" + "unicode/utf8" "github.com/spf13/cobra" "go.kenn.io/kata/internal/textsafe" + "golang.org/x/text/unicode/norm" ) // newSearchCmd returns the cobra.Command for `kata search`. It calls the @@ -169,9 +172,13 @@ func printSearchResults(cmd *cobra.Command, bs []byte) error { DegradedReason string `json:"degraded_reason"` Results []struct { Issue struct { - ShortID string `json:"short_id"` - Title string `json:"title"` - Status string `json:"status"` + ShortID string `json:"short_id"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + Owner *string `json:"owner"` + Priority *int64 `json:"priority"` + Revision int64 `json:"revision"` } `json:"issue"` Score float64 `json:"score"` MatchedIn []string `json:"matched_in"` @@ -196,13 +203,19 @@ func printSearchResults(cmd *cobra.Command, bs []byte) error { return err } for _, r := range b.Results { - if err := writeAgentKVRow(out, + excerpt := searchAgentExcerpt(b.Query, r.Issue.Body) + fields := []agentField{ agentRowField("issue", r.Issue.ShortID), agentRowFloatField("score", r.Score), agentRowField("status", r.Issue.Status), agentRowListField("matched", r.MatchedIn), agentRowField("title", r.Issue.Title), - ); err != nil { + agentOptionalRowField("owner", r.Issue.Owner), + agentRowIntField("priority", r.Issue.Priority), + agentRowField("revision", fmt.Sprint(r.Issue.Revision)), + agentOptionalRowField("excerpt", &excerpt), + } + if err := writeAgentKVRow(out, fields...); err != nil { return err } } @@ -247,3 +260,132 @@ func printSearchResults(cmd *cobra.Command, bs []byte) error { } return nil } + +const agentSearchExcerptLimit = 160 + +type searchExcerptToken struct { + value string + runeOffset int + runeLength int +} + +func searchAgentExcerpt(query, body string) string { + words := strings.Fields(body) + if len(words) == 0 { + return "" + } + queryTokens := tokenizeSearchExcerpt(query) + hit := 0 + matchOffset := 0 + matchLength := 0 + found := false + for i, word := range words { + for _, candidate := range tokenizeSearchExcerpt(word) { + for _, queryToken := range queryTokens { + if candidate.value == queryToken.value { + hit = i + matchOffset = candidate.runeOffset + matchLength = candidate.runeLength + found = true + break + } + } + if found { + break + } + } + if found { + break + } + } + start := 0 + if found { + start = max(0, hit-8) + } + end := min(len(words), start+24) + excerptRunes := []rune(strings.Join(words[start:end], " ")) + focusStart := -1 + if found { + focusStart = matchOffset + for i := start; i < hit; i++ { + focusStart += utf8.RuneCountInString(words[i]) + 1 + } + } + leftTrimmed := start > 0 + rightTrimmed := end < len(words) + markerRunes := 0 + if leftTrimmed { + markerRunes += 2 + } + if rightTrimmed { + markerRunes += 2 + } + if len(excerptRunes)+markerRunes <= agentSearchExcerptLimit { + return formatSearchExcerpt(excerptRunes, leftTrimmed, rightTrimmed) + } + + // Reserve room for both ellipsis markers. Center the bounded window on + // the matched query term so long context before it cannot hide the match. + windowSize := agentSearchExcerptLimit - 4 + windowStart := 0 + if focusStart >= 0 { + focusEnd := focusStart + matchLength + windowStart = max(0, (focusStart+focusEnd-windowSize)/2) + windowStart = min(windowStart, max(0, len(excerptRunes)-windowSize)) + } + windowEnd := min(len(excerptRunes), windowStart+windowSize) + return formatSearchExcerpt( + excerptRunes[windowStart:windowEnd], + leftTrimmed || windowStart > 0, + rightTrimmed || windowEnd < len(excerptRunes), + ) +} + +func tokenizeSearchExcerpt(value string) []searchExcerptToken { + runes := []rune(value) + tokens := make([]searchExcerptToken, 0, len(strings.Fields(value))) + for i := 0; i < len(runes); { + if !isSearchExcerptTokenRune(runes[i]) { + i++ + continue + } + start := i + for i < len(runes) && isSearchExcerptTokenRune(runes[i]) { + i++ + } + tokens = append(tokens, searchExcerptToken{ + value: foldSearchExcerptToken(string(runes[start:i])), + runeOffset: start, + runeLength: i - start, + }) + } + return tokens +} + +// foldSearchExcerptToken lowercases and strips combining marks so excerpt +// matching folds diacritics the way both lexical search backends do. +func foldSearchExcerptToken(value string) string { + var b strings.Builder + for _, r := range norm.NFD.String(value) { + if unicode.Is(unicode.Mn, r) { + continue + } + b.WriteRune(unicode.ToLower(r)) + } + return b.String() +} + +func isSearchExcerptTokenRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsMark(r) +} + +func formatSearchExcerpt(runes []rune, leftTrimmed, rightTrimmed bool) string { + excerpt := string(runes) + if leftTrimmed { + excerpt = "… " + excerpt + } + if rightTrimmed { + excerpt += " …" + } + return excerpt +} diff --git a/cmd/kata/search_test.go b/cmd/kata/search_test.go index 7ba716ec..4ef3c494 100644 --- a/cmd/kata/search_test.go +++ b/cmd/kata/search_test.go @@ -140,6 +140,51 @@ func TestSearchAgentAppendsMode(t *testing.T) { } } +func TestSearchAgentIncludesBoundedTaskContext(t *testing.T) { + body := `{"query":"needle","mode":"lexical","results":[ + {"issue":{"short_id":"abc4","title":"Investigate the worker","body":"zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen needle nearby context sixteen seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven twenty-eight twenty-nine thirty thirty-one thirty-two thirty-three distant-tail","status":"open","owner":"alice","priority":2,"revision":7},"score":1.2,"matched_in":["body"]}]}` + out := renderSearch(t, outputAgent, body) + + assert.Contains(t, out, "issue=abc4") + assert.Contains(t, out, `title="Investigate the worker"`) + assert.Contains(t, out, "status=open") + assert.Contains(t, out, "owner=alice") + assert.Contains(t, out, "priority=2") + assert.Contains(t, out, "revision=7") + assert.Contains(t, out, "needle nearby context") + assert.NotContains(t, out, "distant-tail") + assert.NotContains(t, out, "body=") +} + +func TestSearchAgentExcerptKeepsMatchAfterLongContext(t *testing.T) { + prefix := strings.Repeat("supercalifragilisticexpialidociousword ", 8) + excerpt := searchAgentExcerpt("needle", prefix+"needle useful context after the match") + + assert.Contains(t, excerpt, "needle") + assert.LessOrEqual(t, len([]rune(excerpt)), agentSearchExcerptLimit) +} + +func TestSearchAgentExcerptSplitsQueryPunctuationLikeSearch(t *testing.T) { + prefix := strings.Repeat("prefix ", 30) + excerpt := searchAgentExcerpt("foo-bar", prefix+"foo bar useful context") + + assert.Contains(t, excerpt, "foo bar useful context") +} + +func TestSearchAgentExcerptDoesNotMatchInsideAnotherToken(t *testing.T) { + body := "catalog " + strings.Repeat("filler ", 30) + "log useful context" + excerpt := searchAgentExcerpt("log", body) + + assert.Contains(t, excerpt, "log useful context") +} + +func TestSearchAgentExcerptFoldsDiacriticsLikeSearch(t *testing.T) { + prefix := strings.Repeat("prefix ", 30) + + assert.Contains(t, searchAgentExcerpt("cafe", prefix+"café useful context"), "café useful context") + assert.Contains(t, searchAgentExcerpt("café", prefix+"cafe useful context"), "cafe useful context") +} + // TestSearch_ModeFlagsMutuallyExclusive pins that --lexical/--hybrid/--semantic // cannot be combined; each conflicting pair is a validation error. func TestSearch_ModeFlagsMutuallyExclusive(t *testing.T) { diff --git a/cmd/kata/show.go b/cmd/kata/show.go index 9ea9c133..c7a57ccc 100644 --- a/cmd/kata/show.go +++ b/cmd/kata/show.go @@ -251,6 +251,7 @@ type showResponseForCLI struct { Author string `json:"author"` Owner *string `json:"owner"` Priority *int64 `json:"priority"` + Revision int64 `json:"revision"` Metadata map[string]json.RawMessage `json:"metadata"` } `json:"issue"` Comments []struct { @@ -361,6 +362,9 @@ func printShowAgent(w io.Writer, b showResponseForCLI, subjectProject, operation return err } } + if err := writeAgentField(w, "Revision", fmt.Sprint(b.Issue.Revision)); err != nil { + return err + } if len(b.Issue.Metadata) > 0 { if _, err := fmt.Fprintln(w, "Metadata:"); err != nil { return err diff --git a/cmd/kata/show_test.go b/cmd/kata/show_test.go index 13186279..53c06279 100644 --- a/cmd/kata/show_test.go +++ b/cmd/kata/show_test.go @@ -238,6 +238,16 @@ func TestShow_AgentOutputRendersIssueBodyLabelsAndComments(t *testing.T) { assert.NotContains(t, out, "Owner:") } +func TestShow_AgentOutputIncludesRevision(t *testing.T) { + env, dir, pid := setupCLIWorkspace(t) + ref := createIssue(t, env, pid, "revision task") + runCLI(t, env, dir, "meta", "set", ref, "work.attention", "needs-human") + + out := runCLI(t, env, dir, "--agent", "show", ref) + + assert.Contains(t, out, "Revision: 2\n") +} + func TestShow_AgentOutputLinkRowsUseExistingLinkResponseFields(t *testing.T) { env, dir, pid := setupCLIWorkspace(t) blocker := createIssue(t, env, pid, "blocker") diff --git a/docs/reference/agent-output.md b/docs/reference/agent-output.md index ce9f36b7..c20567a5 100644 --- a/docs/reference/agent-output.md +++ b/docs/reference/agent-output.md @@ -119,9 +119,9 @@ A read emits an `OK count=` header followed by one row per record: ```text OK list count=3 -- issue=abc4 status=open priority=2 owner=agent-a labels=bug,safari title="Fix login race" -- issue=def7 status=open labels=architecture title="Control channel" -- issue=j9k2 status=closed title="Old task" +- issue=abc4 status=open priority=2 owner=agent-a labels=bug,safari title="Fix login race" revision=4 +- issue=def7 status=open labels=architecture title="Control channel" revision=1 +- issue=j9k2 status=closed title="Old task" revision=3 ``` Empty reads emit the header with `count=0` and no rows: @@ -187,10 +187,14 @@ matched it: ```text OK search count=1 query="auth redirect duplicates" mode=hybrid -- issue=abc4 score=0.0312 status=open matched=title,semantic title="Login callback double-submits on Safari" +- issue=abc4 score=0.0312 status=open matched=title,semantic title="Login callback double-submits on Safari" owner=agent-a priority=1 revision=4 excerpt="The callback can submit twice after the auth redirect." ``` -`count`, `query`, and the existing row fields keep their names, positions, and +Search rows append owner, priority, revision, and a body excerpt when present. +The excerpt is at most 160 characters and centers the first query match when +possible, folding case and diacritics like lexical search. `kata show +--agent` remains the complete record and emits `Revision:` after `Priority:`. +List rows append revision after title. Existing row fields keep their names, positions, and meanings, so these additions are purely additive and `agent_format` stays `1`. A daemon without `[search.embeddings]` always reports `mode=lexical` and never sets `degraded=`, so its output is unchanged apart from the appended `mode=`. @@ -257,6 +261,7 @@ Bodies and comments are preserved in fenced `text` blocks: OK show abc4 Issue: abc4 "Fix login race" Status: open +Revision: 4 Body: ```text Safari can double-submit the callback.