Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/kata/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions cmd/kata/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=")
}

Expand Down
152 changes: 147 additions & 5 deletions cmd/kata/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
}
45 changes: 45 additions & 0 deletions cmd/kata/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions cmd/kata/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions cmd/kata/show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 10 additions & 5 deletions docs/reference/agent-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ A read emits an `OK <command> count=<n>` 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:
Expand Down Expand Up @@ -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 <ref>
--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=`.
Expand Down Expand Up @@ -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.
Expand Down
Loading