package companion import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "regexp" "sort" "strconv" "strings" ) // AICArtifact is the deterministic, text representation of the part of an // AI profile that is already expressible by the checked-in AIC grammar. It // is deliberately separate from a packed PVF: callers may inspect and // validate this artifact without opening or modifying a runtime archive. // // The Lua source remains the source of truth for the full profile. AICText // contains only whitelisted scalar AIC tags, so unsupported Lua capabilities // cannot accidentally turn into arbitrary PVF tokens. type AICArtifact struct { Path string `json:"path"` TemplateKey string `json:"template_key"` Job uint8 `json:"job"` ClassID uint16 `json:"class_id"` Behavior string `json:"behavior"` ComboPreset string `json:"combo_preset"` APCCode int64 `json:"apc_code"` AICPath string `json:"aic_path"` AICText string `json:"aic_text"` SHA256 string `json:"sha256"` LuaSHA256 string `json:"lua_sha256"` StrategyStatus string `json:"strategy_status"` MissingCapabilities []StrategyCapability `json:"missing_capabilities,omitempty"` } // PVFOverlayManifest is a small offline hand-off artifact. The // companionpvf command is expected to consume Path/AICText and encode it // with the PVF archive's string pool in a copied archive; this manifest // itself never writes runtime/data/dnf/Script.pvf. type PVFOverlayManifest struct { Format string `json:"format"` BasePVF string `json:"base_pvf"` BaseSHA256 string `json:"base_sha256,omitempty"` Artifacts []AICArtifact `json:"artifacts"` ManifestHash string `json:"manifest_sha256"` } var aicScalarTags = []string{ "[think term]", "[destination change term]", "[keep distance with target]", "[warlike]", "[vision]", } var aicScalarLine = regexp.MustCompile(`^\[([^\]]+)\]\s*(-?[0-9]+)\s*$`) // CompileAICArtifact lowers a validated profile into an inspectable AIC // artifact. It intentionally does not claim that the artifact has been // consumed by a native client; that claim belongs to the action-receipt gate. func CompileAICArtifact(profile AIProfile, caps StrategyCapabilities) (AICArtifact, error) { if !caps.ThinkTerm || !caps.Distance { return AICArtifact{}, fmt.Errorf("AIC scalar compiler requires think_term and target_distance capabilities") } job := uint8(profile.ClassID) return CompileAICArtifactForJob(profile, caps, job) } // CompileAICArtifactForJob is the job-aware form used by dungeon projection. // ClassID selects the learned combo branch; job selects the native profession // family and therefore the verified APC/AIC pair. func CompileAICArtifactForJob(profile AIProfile, caps StrategyCapabilities, job uint8) (AICArtifact, error) { compiled, err := CompileAIProfile(profile, caps) if err != nil { return AICArtifact{}, err } profile = NormalizeAIProfile(profile) selector := SelectAITemplate(profile, uint16(job)) tuning := EffectiveAITuning(profile.Tuning) lines := []string{ "[think term]", strconv.Itoa(tuning.ThinkIntervalMS), "[destination change term]", strconv.Itoa(tuning.RetryMS), "[keep distance with target]", strconv.Itoa(tuning.FollowDistance), "[warlike]", strconv.Itoa(aicWarlike(profile)), "[vision]", strconv.Itoa(tuning.VisionRange), } text := strings.Join(lines, "\n") + "\n" if _, err := ReadAICArtifact(text); err != nil { return AICArtifact{}, fmt.Errorf("generated AIC failed readback: %w", err) } return AICArtifact{ // Path is the real AICharacter.lst path that the PVF overlay replaces. // TemplateKey/LuaSHA256 keep the profile identity auditable even when // two profiles share one native profession template. Path: selector.AICPath, TemplateKey: selector.Key, Job: job, ClassID: selector.ClassID, Behavior: selector.Behavior, ComboPreset: selector.ComboPreset, APCCode: selector.APCCode, AICPath: selector.AICPath, AICText: text, SHA256: sha256Hex([]byte(text)), LuaSHA256: compiled.LuaSHA256, StrategyStatus: compiled.Status, MissingCapabilities: append([]StrategyCapability(nil), compiled.MissingCapabilities...), }, nil } func aicWarlike(profile AIProfile) int { if profile.Behavior == "aggressive" { return 80 } return 0 } func artifactSlug(value string) string { value = strings.ToLower(strings.TrimSpace(value)) var b strings.Builder lastDash := false for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' { b.WriteRune(r) lastDash = false continue } if !lastDash { b.WriteByte('-') lastDash = true } } out := strings.Trim(b.String(), "-") if out == "" { return "auto" } return out } // ReadAICArtifact validates and indexes a generated AIC text artifact. The // parser accepts exactly one scalar value for each generated tag and rejects // unknown tags, duplicate tags, malformed numbers, or non-whitespace data. func ReadAICArtifact(text string) (map[string]int, error) { allowed := make(map[string]struct{}, len(aicScalarTags)) for _, tag := range aicScalarTags { allowed[tag] = struct{}{} } values := make(map[string]int, len(aicScalarTags)) lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") for i := 0; i < len(lines); { line := strings.TrimSpace(lines[i]) if line == "" { i++ continue } match := aicScalarLine.FindStringSubmatch(line) var tag string var number string if len(match) == 3 { tag = "[" + strings.ToLower(match[1]) + "]" number = match[2] } else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { tag = "[" + strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + "]" if i+1 >= len(lines) || strings.TrimSpace(lines[i+1]) == "" { return nil, fmt.Errorf("line %d: scalar tag %q has no value", i+1, tag) } number = strings.TrimSpace(lines[i+1]) } else { return nil, fmt.Errorf("line %d: expected [tag] followed by an integer", i+1) } if _, ok := allowed[tag]; !ok { return nil, fmt.Errorf("line %d: unsupported AIC tag %q", i+1, tag) } if _, ok := values[tag]; ok { return nil, fmt.Errorf("line %d: duplicate AIC tag %q", i+1, tag) } value, err := strconv.Atoi(number) if err != nil { return nil, fmt.Errorf("line %d: invalid value: %w", i+1, err) } values[tag] = value if len(match) == 3 { i++ } else { i += 2 } } if len(values) != len(aicScalarTags) { return nil, fmt.Errorf("AIC scalar set incomplete: got %d of %d tags", len(values), len(aicScalarTags)) } return values, nil } // NewPVFOverlayManifest builds a stable JSON manifest for an offline PVF // overlay. The hash is computed over canonical JSON with ManifestHash // cleared, making repeat compilation byte-for-byte deterministic. func NewPVFOverlayManifest(basePVF, baseSHA256 string, artifacts []AICArtifact) (PVFOverlayManifest, error) { if strings.TrimSpace(basePVF) == "" { return PVFOverlayManifest{}, fmt.Errorf("base PVF path is required") } copyArtifacts := append([]AICArtifact(nil), artifacts...) sort.Slice(copyArtifacts, func(i, j int) bool { return copyArtifacts[i].Path < copyArtifacts[j].Path }) manifest := PVFOverlayManifest{ Format: "dnf90-companion-pvf-overlay-v1", BasePVF: basePVF, BaseSHA256: baseSHA256, Artifacts: copyArtifacts, } manifest.ManifestHash = manifestHash(manifest) return manifest, nil } func manifestHash(manifest PVFOverlayManifest) string { manifest.ManifestHash = "" data, _ := json.Marshal(manifest) return sha256Hex(data) } // ValidatePVFOverlayManifest checks both each AIC readback and the manifest // hash. It is suitable for a CI/readback step after an offline artifact was // copied to a review directory. func ValidatePVFOverlayManifest(manifest PVFOverlayManifest) error { if manifest.Format != "dnf90-companion-pvf-overlay-v1" { return fmt.Errorf("unsupported overlay format %q", manifest.Format) } if strings.TrimSpace(manifest.BasePVF) == "" || len(manifest.Artifacts) == 0 { return fmt.Errorf("overlay must contain base PVF and at least one AIC artifact") } seen := make(map[string]struct{}, len(manifest.Artifacts)) for _, artifact := range manifest.Artifacts { if artifact.Path == "" { return fmt.Errorf("artifact path is empty") } selector := AITemplateSelector{ Key: artifact.TemplateKey, Job: artifact.Job, ClassID: artifact.ClassID, Behavior: artifact.Behavior, ComboPreset: artifact.ComboPreset, APCCode: artifact.APCCode, AICPath: artifact.AICPath, BaseAICPath: artifact.AICPath, } if err := ValidateAITemplateSelector(selector); err != nil { return fmt.Errorf("%s: invalid template selector: %w", artifact.Path, err) } if !strings.EqualFold(artifact.Path, artifact.AICPath) { return fmt.Errorf("%s: artifact path must equal AIC path %s", artifact.Path, artifact.AICPath) } if _, ok := seen[artifact.Path]; ok { return fmt.Errorf("duplicate artifact path %q", artifact.Path) } seen[artifact.Path] = struct{}{} if _, err := ReadAICArtifact(artifact.AICText); err != nil { return fmt.Errorf("%s: %w", artifact.Path, err) } if want := sha256Hex([]byte(artifact.AICText)); artifact.SHA256 != want { return fmt.Errorf("%s: artifact hash mismatch", artifact.Path) } } if manifest.ManifestHash == "" || manifest.ManifestHash != manifestHash(manifest) { return fmt.Errorf("manifest hash mismatch") } return nil } func sha256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) }