package dream // This file implements the JSONL distillation step of a /dream run (SPEC §5.3, // PRD US-005 / FR-13): it selects the current project's recent sessions, reads // their transcripts (truncated to a token/byte budget, SPEC §8.2), and — via the // LLM distiller in consolidator.go — turns durable facts into new memory // entries, deduped against the existing library so nothing already recorded is // re-added. // // The deterministic half lives here (session-window selection, project filter, // transcript rendering, dedup, path construction); the semantic half (which // facts are durable) is a separate LLM call driven by the llmConsolidator. This // keeps the merge/prune pass and its parser (consolidator.go) untouched: the // Runner gathers the transcripts, the consolidator runs a distinct distill // completion with its own prompt/schema, and the distilled NewEntries are folded // into the same ConsolidateResult the Runner already applies (wiring choice "b" // from #523: a separate step appended to the result, chosen over folding it into // the merge/prune prompt so each concern keeps its own input, prompt and schema // and the well-tested merge/prune parser is not perturbed). import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/session" ) // defaultTranscriptBudget bounds the total bytes of session transcript text fed // to the distiller, keeping the distill prompt within the model context even on // long or numerous recent sessions (SPEC §8.2 token budget). Sessions are added // most-recent-first until the budget is reached. const defaultTranscriptBudget = 24000 // distillNewEntryTypes is the closed set of durable memory types the distiller // may emit (PRD FR-13: user / feedback / project / reference). Ephemeral kinds // (checkpoint / progress) are intentionally excluded — one-shot task state is // not distilled into long-term memory. var distillNewEntryTypes = map[string]struct{}{ "user": {}, "feedback": {}, "project": {}, "reference": {}, } // SessionSource is the read-only view of the session store the distiller needs: // list session headers and load a session's messages. *session.Store satisfies // it; tests inject a stub so no real session files (or LLM) are required. type SessionSource interface { List() ([]session.SessionHeader, error) Load(id string) (session.SessionHeader, agentcore.MessageList, error) } // resolveSessionStore opens the default session store rooted at // $PIGO_HOME/sessions (else ~/.pigo/sessions), mirroring // headless.SessionStore's resolution. It is duplicated here rather than imported // to keep internal/dream free of a dependency on the CLI assembly layer, exactly // as ResolveMemoryRoot duplicates the memory-root resolution. A resolution // failure yields a nil source and no error: distillation then degrades to a // no-op rather than failing the whole dream run. func resolveSessionStore() (SessionSource, error) { dir := os.Getenv("PIGO_HOME") if dir == "" { home, err := os.UserHomeDir() if err != nil { return nil, nil } dir = filepath.Join(home, ".pigo") } store, err := session.NewStore(filepath.Join(dir, "sessions")) if err != nil { return nil, nil } return store, nil } // sessionMatchesProject reports whether a session belongs to the project rooted // at projectDir. Attribution is by the session's recorded Cwd: two directories // belong to the same project when their stable project ids match (the same id // internal/memory and BuildPlan derive). A session with an empty Cwd is // unattributed and never matches; an empty projectDir (a global-only run) has no // project to match, so nothing is selected. func sessionMatchesProject(h session.SessionHeader, projectDir string) bool { if projectDir == "" || h.Cwd == "" { return false } return projectID(h.Cwd) == projectID(projectDir) } // collectRecentSessions selects the recent sessions to distill, applying the // SPEC §5.3 combined window over the project-filtered set: // - state.LastRunAt non-zero → every matching session updated strictly after // the last run (incremental distillation since the last dream). // - state.LastRunAt zero (never run) → the most-recent recentN matching // sessions by UpdatedAt descending (a bounded first-run window). // // recentN falls back to DefaultRecentSessions when non-positive. The result is // ordered most-recent-first so transcript budgeting keeps the freshest context. func collectRecentSessions(sessions []session.SessionHeader, state State, projectDir string, recentN int) []session.SessionHeader { if recentN <= 0 { recentN = DefaultRecentSessions } var matched []session.SessionHeader for _, h := range sessions { if sessionMatchesProject(h, projectDir) { matched = append(matched, h) } } // Most-recent-first regardless of the source ordering. sort.SliceStable(matched, func(i, j int) bool { return matched[i].UpdatedAt.After(matched[j].UpdatedAt) }) if !state.LastRunAt.IsZero() { var out []session.SessionHeader for _, h := range matched { if h.UpdatedAt.After(state.LastRunAt) { out = append(out, h) } } return out } if len(matched) > recentN { matched = matched[:recentN] } return matched } // collectTranscripts selects the recent project sessions (collectRecentSessions) // and renders their messages into a single transcript string truncated to // budget bytes (SPEC §8.2). It returns "" when the source is nil or no session // matches — the no-matching-sessions no-op (SPEC §5.5): the caller then records // Distilled=0 with a "无新增" note. A per-session load error is skipped rather // than failing the run (a single corrupt session must not abort distillation). func collectTranscripts(src SessionSource, state State, projectDir string, recentN, budget int) string { if src == nil { return "" } if budget <= 0 { budget = defaultTranscriptBudget } headers, err := src.List() if err != nil { return "" } selected := collectRecentSessions(headers, state, projectDir, recentN) if len(selected) == 0 { return "" } var b strings.Builder for _, h := range selected { if b.Len() >= budget { break } _, msgs, err := src.Load(h.ID) if err != nil { continue } section := renderTranscript(h, msgs) if section == "" { continue } remaining := budget - b.Len() if len(section) > remaining { section = truncateBody(section, remaining) } b.WriteString(section) b.WriteString("\n") } return strings.TrimSpace(b.String()) } // renderTranscript renders one session's messages as a compact, role-tagged // transcript for the distiller. Tool-result bodies are included but bounded so a // single huge tool output cannot dominate the budget; empty messages are // skipped. The header line carries the session id and update time for context. func renderTranscript(h session.SessionHeader, msgs agentcore.MessageList) string { var b strings.Builder b.WriteString(fmt.Sprintf("## Session %s (updated %s)\n", h.ID, h.UpdatedAt.UTC().Format("2006-01-02"))) wrote := false for _, m := range msgs { var role, text string switch mm := m.(type) { case agentcore.UserMessage: role, text = "user", agentcore.ContentToText(mm.Content) case agentcore.AssistantMessage: role, text = "assistant", agentcore.ContentToText(mm.Content) case agentcore.ToolResultMessage: role, text = "tool", clip(agentcore.ContentToText(mm.Content), 500) case agentcore.CompactionMessage: role, text = "summary", mm.Summary default: continue } text = strings.TrimSpace(text) if text == "" { continue } b.WriteString(role) b.WriteString(": ") b.WriteString(text) b.WriteString("\n") wrote = true } if !wrote { return "" } return b.String() } // clip trims s to at most n bytes on a rune boundary, appending an ellipsis when // truncation occurred. Used to bound individual tool-result bodies inside a // transcript so one large output does not crowd out the rest. func clip(s string, n int) string { if n <= 0 || len(s) <= n { return s } cut := n for cut > 0 && !isRuneStart(s[cut]) { cut-- } return s[:cut] + "…" } // distilledEntry mirrors one element of the distiller's strict-JSON output: a // proposed new memory entry with its semantic type, target scope, a short title // (turned into a filename slug) and the Markdown body to persist. type distilledEntry struct { Type string `json:"type"` Scope string `json:"scope"` Title string `json:"title"` Body string `json:"body"` } // distillResponse is the full strict-JSON schema the distill prompt requires. type distillResponse struct { Entries []distilledEntry `json:"entries"` Notes []string `json:"notes"` } // parseDistillResponse turns the distiller's text into concrete NewEntry writes, // deduped against the existing memory bodies. It is conservative by // construction: an unparseable response, an unknown/ephemeral type, an empty // body, or an entry that is a near-duplicate of an existing memory (or of an // already-accepted new entry) is dropped rather than written (PRD FR-13/FR-14). // It never errors; a hard model failure is handled by the caller. // // memoryRoot + projectDir place each entry within the scope the Runner's // withinScope guard permits: global entries under /global//, project // entries under /projects///. A "project" entry on a global-only // run (empty projectDir) has nowhere valid to live and is skipped. func parseDistillResponse(raw string, existing []MemoryFile, memoryRoot, projectDir string) ([]NewEntry, []string) { body := extractJSONObject(raw) if body == "" { return nil, nil } var resp distillResponse if err := json.Unmarshal([]byte(body), &resp); err != nil { return nil, nil } // Precompute token sets of existing bodies for near-duplicate rejection. existingTokens := make([]map[string]struct{}, 0, len(existing)) for _, f := range existing { existingTokens = append(existingTokens, tokenize(f.Body)) } var out []NewEntry var notes []string acceptedTokens := make([]map[string]struct{}, 0) usedPaths := make(map[string]struct{}) for _, e := range resp.Entries { typ := strings.ToLower(strings.TrimSpace(e.Type)) if _, ok := distillNewEntryTypes[typ]; !ok { continue } body := strings.TrimSpace(e.Body) if body == "" { continue } scopeDir, ok := distillScopeDir(e.Scope, projectDir) if !ok { continue } tok := tokenize(body) if isNearDup(tok, existingTokens) || isNearDup(tok, acceptedTokens) { notes = append(notes, fmt.Sprintf("distill: skipped near-duplicate of existing memory (%s)", firstLine(e.Title, body))) continue } path := distillPath(memoryRoot, scopeDir, typ, e.Title, body, usedPaths) usedPaths[filepath.Clean(path)] = struct{}{} acceptedTokens = append(acceptedTokens, tok) out = append(out, NewEntry{Path: path, Body: ensureTrailingNewline(body)}) } for _, n := range resp.Notes { if s := strings.TrimSpace(n); s != "" { notes = append(notes, s) } } return out, notes } // distillScopeDir maps a requested scope ("global"/"project") to its on-disk // scope directory relative to the memory root. A "project" scope requires a // project dir; without one the entry cannot be placed and ok is false. An empty // or unrecognized scope defaults to global (the safe, always-valid target). func distillScopeDir(scope, projectDir string) (string, bool) { switch strings.ToLower(strings.TrimSpace(scope)) { case "project", "projects": if projectDir == "" { return "", false } return filepath.Join("projects", projectID(projectDir)), true default: return "global", true } } // distillPath builds the absolute file path for a new distilled entry: // ///.md. The slug derives from the title // (sanitized) or, when absent, a short content hash, so a re-run of the same // durable fact lands on a stable path (and is caught by the near-dup check // against the now-existing file, keeping distillation idempotent). If the path // is already taken within this run, a short body hash disambiguates it. func distillPath(memoryRoot, scopeDir, typ, title, body string, used map[string]struct{}) string { slug := slugify(title) if slug == "" { slug = shortHash(body) } name := slug + ".md" path := filepath.Join(memoryRoot, scopeDir, typ, name) if _, taken := used[filepath.Clean(path)]; taken { path = filepath.Join(memoryRoot, scopeDir, typ, slug+"-"+shortHash(body)+".md") } return path } // isNearDup reports whether tok is at or above NearDupThreshold Jaccard // similarity with any set in others — the same conservative near-duplicate // signal the deterministic plan uses for merge candidates, reused here so a // distilled fact that already lives in memory is not re-added (PRD FR-13). func isNearDup(tok map[string]struct{}, others []map[string]struct{}) bool { for _, o := range others { if jaccard(tok, o) >= NearDupThreshold { return true } } return false } // slugify turns a title into a lowercase, dash-separated filename stem of ASCII // alphanumerics, bounding the length so paths stay reasonable. Non-alphanumeric // runs collapse to a single dash; leading/trailing dashes are trimmed. A title // with no usable characters yields "". func slugify(title string) string { var b strings.Builder lastDash := false for _, r := range strings.ToLower(strings.TrimSpace(title)) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) lastDash = false default: if !lastDash && b.Len() > 0 { b.WriteByte('-') lastDash = true } } } slug := strings.Trim(b.String(), "-") const maxLen = 60 if len(slug) > maxLen { slug = strings.Trim(slug[:maxLen], "-") } return slug } // firstLine returns a short human-readable label for a note: the trimmed title // when present, else the first line of body, truncated. func firstLine(title, body string) string { s := strings.TrimSpace(title) if s == "" { s = strings.TrimSpace(body) } if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[:i] } return clip(s, 60) } // ensureTrailingNewline guarantees body ends with exactly one newline so written // memory files match the one-entry-per-file convention and diff cleanly. func ensureTrailingNewline(body string) string { return strings.TrimRight(body, "\n") + "\n" } // shortHash returns the first 8 hex chars of sha256(s), a stable disambiguator // for slugs derived from identical/absent titles. func shortHash(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:])[:8] }