// Package opencode — запуск opencode-субагентов и разбор их вердиктов. // // Единственный компонент, умеющий запускать opencode run и парсить вывод. // Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py): // - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json // - ExtractJSON: fenced ```json``` → первый {...} // - Run/ResumeDev: запуск процесса с idle/hard timeout по opencode.db package opencode import ( "encoding/json" "regexp" "strings" ) var ( fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```") jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}") sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`) ) // ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json. // Поток — NDJSON: последний парт {"type":"text","part":{"text":...}} и есть вердикт. // Если JSON-партов нет (например, простой текст) — возвращает исходную строку. func ExtractVerdict(out string) string { out = strings.TrimSpace(out) if out == "" { return "" } var last string for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "{") { continue } var obj struct { Type string `json:"type"` Part struct { Text string `json:"text"` } `json:"part"` } if err := json.Unmarshal([]byte(line), &obj); err != nil { continue } if obj.Type == "text" && obj.Part.Text != "" { last = obj.Part.Text } } if last != "" { return strings.TrimSpace(last) } return out } // ExtractJSON извлекает JSON из ответа модели: сначала fenced ```json```, // затем первый {...}. Возвращает (nil, false), если JSON нет или невалиден. // Это НЕ ошибка — вызывающий решает, как деградировать (класс O3 ErrParse). func ExtractJSON(text string) (map[string]json.RawMessage, bool) { if strings.TrimSpace(text) == "" { return nil, false } for _, f := range fenceRe.FindAllStringSubmatch(text, -1) { if len(f) > 1 { var obj map[string]json.RawMessage if err := json.Unmarshal([]byte(strings.TrimSpace(f[1])), &obj); err == nil && obj != nil { return obj, true } } } if m := jsonBlockRe.FindString(text); m != "" { var obj map[string]json.RawMessage if err := json.Unmarshal([]byte(m), &obj); err == nil && obj != nil { return obj, true } } return nil, false } // SessionIDFromOutput извлекает session_id из текстового вывода opencode. func SessionIDFromOutput(out string) (string, bool) { m := sessionRe.FindStringSubmatch(out) if len(m) > 1 && m[1] != "" { return m[1], true } return "", false } // stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента. // Используется для вердиктов, которые модель может вернуть в markdown-фенсе. func stripFence(s string) string { s = strings.TrimSpace(s) if f := fenceRe.FindStringSubmatch(s); len(f) > 1 { s = strings.TrimSpace(f[1]) } return strings.TrimSpace(s) }