Files
2026-07-13 13:00:08 +08:00

870 lines
24 KiB
Go

package autoresearch
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"unicode"
fileencoding "reasonix/internal/fileutil/encoding"
)
var safeTaskID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
var explicitTaskPath = regexp.MustCompile(`\.reasonix/autoresearch/([A-Za-z0-9][A-Za-z0-9._-]*)/?`)
type Store struct {
workspaceRoot string
root string
mu sync.Mutex
taskLocks map[string]*sync.Mutex
}
func NewStore(workspaceRoot string) *Store {
if resolved, err := filepath.EvalSymlinks(workspaceRoot); err == nil {
workspaceRoot = resolved
}
return &Store{
workspaceRoot: workspaceRoot,
root: filepath.Join(workspaceRoot, ".reasonix", "autoresearch"),
taskLocks: map[string]*sync.Mutex{},
}
}
func (s *Store) lockTask(taskID string) func() {
s.mu.Lock()
lock := s.taskLocks[taskID]
if lock == nil {
lock = &sync.Mutex{}
s.taskLocks[taskID] = lock
}
s.mu.Unlock()
lock.Lock()
return lock.Unlock
}
func (s *Store) CreateTask(goal string, opts CreateOptions) (*Task, error) {
goal = strings.TrimSpace(goal)
if goal == "" {
return nil, errors.New("autoresearch: goal is required")
}
now := time.Now().UTC()
if opts.Now != nil {
now = opts.Now().UTC()
}
id, err := s.nextTaskID(now, goal)
if err != nil {
return nil, err
}
if err := os.MkdirAll(s.root, 0o755); err != nil {
return nil, fmt.Errorf("autoresearch: create root dir: %w", err)
}
storeRoot, err := os.OpenRoot(s.root)
if err != nil {
return nil, fmt.Errorf("autoresearch: open root dir: %w", err)
}
defer storeRoot.Close()
taskRel, err := s.taskRel(id)
if err != nil {
return nil, err
}
if err := storeRoot.MkdirAll(filepath.Join(taskRel, "state"), 0o755); err != nil {
return nil, fmt.Errorf("autoresearch: create state dir: %w", err)
}
if err := storeRoot.MkdirAll(filepath.Join(taskRel, "logs"), 0o755); err != nil {
return nil, fmt.Errorf("autoresearch: create logs dir: %w", err)
}
spec := TaskSpec{
TaskID: id,
Goal: goal,
Scope: append([]string(nil), opts.Scope...),
NonGoals: append([]string(nil), opts.NonGoals...),
AllowedOperations: opts.AllowedOperations,
SuccessCriteria: cloneCriteria(opts.SuccessCriteria),
}
progress := Progress{
Status: StatusRunning,
UpdatedAt: now,
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), spec); err != nil {
return nil, err
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
return nil, err
}
for _, path := range []string{
filepath.Join(taskRel, "state", "directions_tried.json"),
filepath.Join(taskRel, "state", "findings.jsonl"),
filepath.Join(taskRel, "state", "iteration_log.jsonl"),
filepath.Join(taskRel, "logs", "heartbeat.jsonl"),
} {
if err := storeRoot.WriteFile(path, nil, 0o644); err != nil {
return nil, fmt.Errorf("autoresearch: initialize %s: %w", path, err)
}
}
return &Task{ID: id, Root: s.taskRoot(id), Spec: spec}, nil
}
func (s *Store) ListSummaries() ([]Summary, error) {
entries, err := os.ReadDir(s.root)
if err != nil {
if os.IsNotExist(err) {
return []Summary{}, nil
}
return nil, fmt.Errorf("autoresearch: list tasks: %w", err)
}
ids := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
id := entry.Name()
if validateTaskID(id) != nil {
continue
}
ids = append(ids, id)
}
sort.Sort(sort.Reverse(sort.StringSlice(ids)))
out := make([]Summary, 0, len(ids))
for _, id := range ids {
summary, err := s.Summary(id)
if err != nil {
return nil, err
}
out = append(out, *summary)
}
return out, nil
}
func (s *Store) LoadTask(taskID string) (*Task, error) {
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
info, err := storeRoot.Lstat(taskRel)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("autoresearch: task %s not found", taskID)
}
return nil, fmt.Errorf("autoresearch: stat task %s: %w", taskID, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("autoresearch: task %s is a symlink", taskID)
}
if !info.IsDir() {
return nil, fmt.Errorf("autoresearch: task %s is not a directory", taskID)
}
var spec TaskSpec
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
return nil, err
}
return &Task{ID: taskID, Root: s.taskRoot(taskID), Spec: spec}, nil
}
func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error) {
match := explicitTaskPath.FindStringSubmatch(goal)
if len(match) < 2 {
return nil, false, nil
}
task, err := s.LoadTask(match[1])
if err != nil {
return nil, true, err
}
if report, err := s.ValidateTask(task.ID); err != nil {
return nil, true, err
} else if !report.Valid {
return nil, true, fmt.Errorf("autoresearch: task %s is invalid: %v", task.ID, report.Errors)
}
return task, true, nil
}
func (s *Store) AppendFinding(taskID string, f Finding) error {
if err := validateTaskID(taskID); err != nil {
return err
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return err
}
defer storeRoot.Close()
if err := validateFinding(f); err != nil {
return err
}
data, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("autoresearch: marshal finding: %w", err)
}
return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data)
}
func (s *Store) RecordEvidence(taskID, criterionID string, f Finding) error {
if err := validateTaskID(taskID); err != nil {
return err
}
criterionID = strings.TrimSpace(criterionID)
if criterionID == "" {
return errors.New("autoresearch: criterion id is required")
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return err
}
defer storeRoot.Close()
if err := validateFinding(f); err != nil {
return err
}
specPath := filepath.Join(taskRel, "state", "task_spec.json")
var spec TaskSpec
if err := readJSONFile(storeRoot, specPath, &spec); err != nil {
return err
}
found := false
for i := range spec.SuccessCriteria {
if spec.SuccessCriteria[i].ID != criterionID {
continue
}
found = true
if !stringSliceContains(spec.SuccessCriteria[i].EvidenceIDs, f.ID) {
spec.SuccessCriteria[i].EvidenceIDs = append(spec.SuccessCriteria[i].EvidenceIDs, f.ID)
}
break
}
if !found {
return fmt.Errorf("autoresearch: criterion %q not found", criterionID)
}
if err := writeJSONFile(storeRoot, specPath, spec); err != nil {
return err
}
data, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("autoresearch: marshal finding: %w", err)
}
return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data)
}
func (s *Store) Findings(taskID string, limit int) ([]Finding, error) {
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
path := filepath.Join(taskRel, "state", "findings.jsonl")
// Bounded requests (the newest-N views) read only the file tail; limit 0
// keeps the full scan because accepted-evidence lookups need every entry.
lines, err := tailJSONLLines(storeRoot, path, limit)
if err != nil {
return nil, err
}
var findings []Finding
for _, line := range lines {
var f Finding
if err := json.Unmarshal(fileencoding.DecodeToUTF8(line), &f); err != nil {
return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
}
findings = append(findings, f)
}
for i, j := 0, len(findings)-1; i < j; i, j = i+1, j-1 {
findings[i], findings[j] = findings[j], findings[i]
}
if limit > 0 && len(findings) > limit {
findings = findings[:limit]
}
return findings, nil
}
func (s *Store) AppendHeartbeat(taskID string, h Heartbeat) error {
if err := validateTaskID(taskID); err != nil {
return err
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return err
}
defer storeRoot.Close()
if err := validateHeartbeat(h); err != nil {
return err
}
data, err := json.Marshal(h)
if err != nil {
return fmt.Errorf("autoresearch: marshal heartbeat: %w", err)
}
return appendJSONL(storeRoot, filepath.Join(taskRel, "logs", "heartbeat.jsonl"), data)
}
func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) {
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
path := filepath.Join(taskRel, "logs", "heartbeat.jsonl")
// Bounded requests only need the file tail; heartbeat logs grow one line
// per turn for the life of a task, so a full scan per read is a per-turn
// cost that keeps rising.
lines, err := tailJSONLLines(storeRoot, path, limit)
if err != nil {
return nil, err
}
var heartbeats []Heartbeat
for _, line := range lines {
var h Heartbeat
if err := json.Unmarshal(fileencoding.DecodeToUTF8(line), &h); err != nil {
return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
}
heartbeats = append(heartbeats, h)
}
if limit > 0 && len(heartbeats) > limit {
heartbeats = heartbeats[len(heartbeats)-limit:]
}
return heartbeats, nil
}
func (s *Store) LastHeartbeat(taskID string) (Heartbeat, bool, error) {
heartbeats, err := s.Heartbeats(taskID, 1)
if err != nil {
return Heartbeat{}, false, err
}
if len(heartbeats) == 0 {
return Heartbeat{}, false, nil
}
return heartbeats[0], true, nil
}
func (s *Store) RecordDirection(taskID string, d Direction) (*Progress, error) {
if err := validateTaskID(taskID); err != nil {
return nil, err
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
d.Summary = strings.TrimSpace(d.Summary)
if d.Summary == "" {
return nil, errors.New("autoresearch: direction summary is required")
}
now := d.Now.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
var progress Progress
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
return nil, err
}
progress.Iteration++
progress.CurrentDirection = d.Summary
progress.UpdatedAt = now
directions, err := s.loadDirections(storeRoot, taskRel)
if err != nil {
return nil, err
}
fp := directionFingerprint(d.Summary)
repeated := false
for i := range directions {
// Legacy entries carry pre-hash fingerprints; recompute from the
// stored summary so repeats recorded by older versions still match,
// then migrate the entry in place.
if directions[i].Fingerprint != fp && directionFingerprint(directions[i].Summary) != fp {
continue
}
repeated = true
directions[i].Fingerprint = fp
directions[i].Count++
directions[i].LastSeenIteration = progress.Iteration
break
}
if !repeated {
directions = append(directions, DirectionTried{
Fingerprint: fp,
Summary: d.Summary,
FirstSeenIteration: progress.Iteration,
LastSeenIteration: progress.Iteration,
Count: 1,
})
}
if repeated || len(d.AcceptedEvidenceIDs) == 0 {
before := progress.StaleCount
progress.StaleCount++
if before < 2 && progress.StaleCount >= 2 {
progress.PivotCount++
}
} else {
progress.StaleCount = 0
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "directions_tried.json"), directions); err != nil {
return nil, err
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
return nil, err
}
return &progress, nil
}
func (s *Store) UpdateProgress(taskID string, patch ProgressPatch) (*Progress, error) {
if err := validateTaskID(taskID); err != nil {
return nil, err
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
var progress Progress
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
return nil, err
}
if patch.Status != nil {
progress.Status = strings.TrimSpace(*patch.Status)
}
if patch.CurrentDirection != nil {
progress.CurrentDirection = strings.TrimSpace(*patch.CurrentDirection)
}
if patch.BlockedReason != nil {
progress.BlockedReason = strings.TrimSpace(*patch.BlockedReason)
}
progress.UpdatedAt = time.Now().UTC()
report := &ValidationReport{Valid: true}
validateProgress(report, progress)
if len(report.Errors) > 0 {
return nil, fmt.Errorf("autoresearch: invalid progress patch: %v", report.Errors)
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
return nil, err
}
return &progress, nil
}
func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) {
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
report := &ValidationReport{Valid: true}
info, err := storeRoot.Lstat(taskRel)
if err != nil {
report.add("task", "", err.Error())
report.Valid = false
return report, nil
}
if info.Mode()&os.ModeSymlink != 0 {
report.add("task", "", "task directory must not be a symlink")
report.Valid = false
return report, nil
}
if !info.IsDir() {
report.add("task", "", "task path is not a directory")
report.Valid = false
return report, nil
}
var spec TaskSpec
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
report.add("task_spec.json", "", err.Error())
} else {
validateTaskSpec(report, taskID, spec)
}
var progress Progress
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
report.add("progress.json", "", err.Error())
} else {
validateProgress(report, progress)
}
for _, rel := range []string{
"state/directions_tried.json",
"state/findings.jsonl",
"state/iteration_log.jsonl",
"logs/heartbeat.jsonl",
} {
if _, err := storeRoot.Stat(filepath.Join(taskRel, rel)); err != nil {
report.add(filepath.Base(rel), "", err.Error())
}
}
report.Valid = len(report.Errors) == 0
return report, nil
}
func (s *Store) taskRoot(taskID string) string {
return filepath.Join(s.root, taskID)
}
func (s *Store) taskRel(taskID string, parts ...string) (string, error) {
if err := validateTaskID(taskID); err != nil {
return "", err
}
all := append([]string{taskID}, parts...)
rel := filepath.Join(all...)
if !filepath.IsLocal(rel) {
return "", fmt.Errorf("autoresearch: unsafe task-relative path %q", rel)
}
return rel, nil
}
func (s *Store) openTaskRoot(taskID string) (*os.Root, string, error) {
taskRel, err := s.taskRel(taskID)
if err != nil {
return nil, "", err
}
storeRoot, err := os.OpenRoot(s.root)
if err != nil {
if os.IsNotExist(err) {
return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
}
return nil, "", fmt.Errorf("autoresearch: open root dir: %w", err)
}
return storeRoot, taskRel, nil
}
func (s *Store) nextTaskID(now time.Time, goal string) (string, error) {
if err := os.MkdirAll(s.root, 0o755); err != nil {
return "", fmt.Errorf("autoresearch: create root dir: %w", err)
}
storeRoot, err := os.OpenRoot(s.root)
if err != nil {
return "", fmt.Errorf("autoresearch: open root dir: %w", err)
}
defer storeRoot.Close()
base := now.Format("20060102-150405") + "-" + slugify(goal)
if base == now.Format("20060102-150405")+"-" {
base += "task"
}
id := base
for i := 2; ; i++ {
taskRel, err := s.taskRel(id)
if err != nil {
return "", err
}
if _, err := storeRoot.Lstat(taskRel); err != nil {
if os.IsNotExist(err) {
return id, nil
}
return "", fmt.Errorf("autoresearch: stat task id %s: %w", id, err)
}
id = fmt.Sprintf("%s-%d", base, i)
}
}
func validateTaskID(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return errors.New("autoresearch: task id is required")
}
if !safeTaskID.MatchString(id) || strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) {
return fmt.Errorf("autoresearch: unsafe task id %q", id)
}
return nil
}
func writeJSONFile(root *os.Root, path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Errorf("autoresearch: marshal %s: %w", path, err)
}
data = append(data, '\n')
if err := root.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("autoresearch: write %s: %w", path, err)
}
return nil
}
func readJSONFile(root *os.Root, path string, out any) error {
data, err := root.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
data = fileencoding.DecodeToUTF8(data)
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
return nil
}
func appendJSONL(root *os.Root, path string, data []byte) error {
if err := root.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("autoresearch: create jsonl dir: %w", err)
}
f, err := root.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("autoresearch: open %s: %w", path, err)
}
defer f.Close()
if _, err := f.Write(append(data, '\n')); err != nil {
return fmt.Errorf("autoresearch: append %s: %w", path, err)
}
return nil
}
func readJSONL(root *os.Root, path string, each func([]byte) error) error {
f, err := root.Open(path)
if err != nil {
return fmt.Errorf("autoresearch: open %s: %w", path, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if err := each([]byte(line)); err != nil {
return fmt.Errorf("autoresearch: parse %s: %w", path, err)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("autoresearch: scan %s: %w", path, err)
}
return nil
}
// tailJSONLLines returns the last limit non-empty lines of a JSONL file in
// file order, reading backward in fixed-size chunks so per-turn readers do not
// rescan an append-only log that grows for the life of a task. limit <= 0
// reads the whole file (legacy unbounded behavior).
func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
if limit <= 0 {
var lines [][]byte
if err := readJSONL(root, path, func(data []byte) error {
line := make([]byte, len(data))
copy(line, data)
lines = append(lines, line)
return nil
}); err != nil {
return nil, err
}
return lines, nil
}
f, err := root.Open(path)
if err != nil {
return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("autoresearch: stat %s: %w", path, err)
}
const chunkSize = 64 * 1024
var (
buf []byte
off = info.Size()
)
for off > 0 {
readLen := int64(chunkSize)
if off < readLen {
readLen = off
}
off -= readLen
chunk := make([]byte, readLen)
if _, err := f.ReadAt(chunk, off); err != nil {
return nil, fmt.Errorf("autoresearch: read %s: %w", path, err)
}
buf = append(chunk, buf...)
// Stop once the buffered tail holds enough complete lines. Count
// newline-separated non-empty segments after the first newline (the
// first segment may be a partial line unless we reached offset 0).
if countCompleteTailLines(buf, off == 0) > limit {
break
}
}
segments := strings.Split(string(buf), "\n")
if off > 0 && len(segments) > 0 {
segments = segments[1:] // drop the leading partial line
}
var lines [][]byte
for _, seg := range segments {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
lines = append(lines, []byte(seg))
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
return lines, nil
}
func countCompleteTailLines(buf []byte, atStart bool) int {
segments := strings.Split(string(buf), "\n")
if !atStart && len(segments) > 0 {
segments = segments[1:]
}
count := 0
for _, seg := range segments {
if strings.TrimSpace(seg) != "" {
count++
}
}
return count
}
func (s *Store) loadDirections(root *os.Root, taskRel string) ([]DirectionTried, error) {
path := filepath.Join(taskRel, "state", "directions_tried.json")
data, err := root.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
data = fileencoding.DecodeToUTF8(data)
if strings.TrimSpace(string(data)) == "" {
return nil, nil
}
var directions []DirectionTried
if err := json.Unmarshal(data, &directions); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return directions, nil
}
func validateFinding(f Finding) error {
if strings.TrimSpace(f.ID) == "" {
return errors.New("autoresearch: finding id is required")
}
switch f.Kind {
case FindingKindCommand, FindingKindFile, FindingKindTest, FindingKindBenchmark, FindingKindManual, FindingKindReview:
default:
return fmt.Errorf("autoresearch: finding kind %q is invalid", f.Kind)
}
if strings.TrimSpace(f.Summary) == "" {
return errors.New("autoresearch: finding summary is required")
}
if f.CreatedAt.IsZero() {
return errors.New("autoresearch: finding created_at is required")
}
return nil
}
func validateHeartbeat(h Heartbeat) error {
switch h.Status {
case HeartbeatStartingTurn, HeartbeatTurnDone, HeartbeatWarning:
default:
return fmt.Errorf("autoresearch: heartbeat status %q is invalid", h.Status)
}
if h.Iteration < 0 {
return errors.New("autoresearch: heartbeat iteration must not be negative")
}
if h.CreatedAt.IsZero() {
return errors.New("autoresearch: heartbeat created_at is required")
}
return nil
}
func stringSliceContains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func cloneCriteria(in []SuccessCriterion) []SuccessCriterion {
out := make([]SuccessCriterion, len(in))
for i, c := range in {
out[i] = c
out[i].EvidenceIDs = append([]string(nil), c.EvidenceIDs...)
}
return out
}
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
lastDash := false
for _, r := range s {
switch {
case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
b.WriteRune(r)
lastDash = false
default:
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
}
slug := strings.Trim(b.String(), "-")
const maxSlugLen = 56
if len(slug) > maxSlugLen {
slug = strings.Trim(slug[:maxSlugLen], "-")
}
if slug == "" {
return "task"
}
return slug
}
// directionFingerprint identifies a direction for repeat detection. slugify
// alone truncates to 56 chars and drops non-ASCII runes, so two different
// directions sharing a long ASCII prefix (or differing only in CJK text)
// collapsed to one fingerprint and wrongly inflated StaleCount/PivotCount on
// long tasks. Append a hash of the full normalized text when slugify lost
// distinguishing content (truncation or non-ASCII letters/digits); keep the
// bare slug otherwise so fingerprints recorded by older versions still match,
// and punctuation-only differences stay fuzzy-matched as before.
func directionFingerprint(summary string) string {
slug := slugify(summary)
if slug == slugifyUnbounded(summary) && !containsNonASCIIWord(summary) {
return slug
}
normalized := strings.Join(strings.Fields(strings.ToLower(summary)), " ")
h := fnv.New32a()
_, _ = h.Write([]byte(normalized))
return fmt.Sprintf("%s-%08x", slug, h.Sum32())
}
// slugifyUnbounded matches slugify without the 56-char cap, used to detect
// whether truncation dropped content.
func slugifyUnbounded(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
lastDash := false
for _, r := range s {
switch {
case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
b.WriteRune(r)
lastDash = false
default:
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
}
slug := strings.Trim(b.String(), "-")
if slug == "" {
return "task"
}
return slug
}
// containsNonASCIIWord reports whether s carries letters/digits that slugify
// discards entirely (e.g. CJK), meaning distinct summaries could share a slug.
func containsNonASCIIWord(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
return true
}
}
return false
}