Files
2026-08-14 23:41:57 +08:00

360 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dream
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"sort"
"strings"
"unicode"
)
// NearDupThreshold is the normalized-token Jaccard similarity at or above which
// two memory files are emitted as a near-duplicate candidate pair for the later
// LLM merge-decision step (spec §5.1.1). It is deliberately conservative: this
// node only PAIRS candidates, it never merges, so a false-positive pair costs
// only one extra LLM comparison while a missed pair silently loses a merge
// opportunity.
const NearDupThreshold = 0.7
// MemoryFile is one enumerated memory file under the global or the active
// project scope, with the derived metadata the deterministic plan needs. Body
// is retained so the near-duplicate pairing can tokenize it without a second
// read.
type MemoryFile struct {
Path string // absolute path on disk
Scope string // "global" | "projects"
Type string // layout <type> segment ("" for a file directly under the scope root)
Size int64 // byte size of the file content
ContentHash string // sha256 hex of the raw file content
Body string // full file content (used for path extraction + tokenization)
}
// DedupeGroup is a set of two or more files whose content is byte-identical
// (same ContentHash). The apply node keeps one representative and removes the
// rest; Deduped in the Report counts len(Paths)-1 per group.
type DedupeGroup struct {
Hash string `json:"hash"`
Paths []string `json:"paths"`
}
// InvalidPathRef is a local filesystem path mentioned in a memory file's body
// that no longer exists on disk. External references (URLs, mailto:, etc.) are
// never recorded here (spec §5.2 / PRD US-004).
type InvalidPathRef struct {
File string `json:"file"` // memory file containing the reference
Ref string `json:"ref"` // the original (unresolved) reference text
}
// NearDupPair is a candidate pair of files whose token-set similarity is at or
// above NearDupThreshold but whose content is not byte-identical. It is a
// suggestion for the LLM to decide whether an actual merge is warranted; this
// node performs no merge.
type NearDupPair struct {
A string `json:"a"`
B string `json:"b"`
Similarity float64 `json:"similarity"`
}
// Plan is the plain-data output of the deterministic half of a /dream run. It
// carries no behavior and makes no LLM calls; the later apply node consumes it
// to drive merges/prunes and to compute the final Report counters.
type Plan struct {
Files []MemoryFile `json:"files"`
DedupeGroups []DedupeGroup `json:"dedupe_groups"`
InvalidPathRefs []InvalidPathRef `json:"invalid_path_refs"`
NearDupPairs []NearDupPair `json:"near_dup_pairs"`
BytesBefore int64 `json:"bytes_before"`
FilesBefore int `json:"files_before"`
}
// BuildPlan enumerates the consolidation-eligible memory files (global scope +
// the given project's scope) under memoryRoot and computes the deterministic
// consolidation plan: exact-dedup groups, invalid local path references, and
// near-duplicate candidate pairs. It excludes the sessions scope entirely and
// any file whose layout type is "checkpoint" (session-transient state, not
// long-term memory — spec §1.3 / §5.1.1).
//
// projectDir is the working directory of the active project; its stable project
// id (matching internal/memory's resolveProjectId) selects the projects
// sub-scope. An empty projectDir restricts the plan to the global scope. A
// missing memoryRoot or scope directory is not an error — it yields an empty
// plan, mirroring memory.Reconcile's tolerance of an absent root.
func BuildPlan(memoryRoot, projectDir string) (Plan, error) {
var plan Plan
globalRoot := filepath.Join(memoryRoot, "global")
globalFiles, err := enumerateScope(globalRoot, "global")
if err != nil {
return Plan{}, err
}
files := globalFiles
if projectDir != "" {
projectRoot := filepath.Join(memoryRoot, "projects", projectID(projectDir))
projFiles, err := enumerateScope(projectRoot, "projects")
if err != nil {
return Plan{}, err
}
files = append(files, projFiles...)
}
plan.Files = files
plan.FilesBefore = len(files)
for _, f := range files {
plan.BytesBefore += f.Size
}
plan.DedupeGroups = dedupeGroups(files)
plan.InvalidPathRefs = invalidPathRefs(files, projectDir)
plan.NearDupPairs = nearDupPairs(files)
return plan, nil
}
// enumerateScope walks scopeRoot for *.md files, deriving each file's layout
// type from its first path segment relative to scopeRoot. Files under a
// "checkpoint" type directory are skipped. A missing scopeRoot yields no files
// and no error.
func enumerateScope(scopeRoot, scope string) ([]MemoryFile, error) {
var out []MemoryFile
err := filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if d.IsDir() || !strings.EqualFold(filepath.Ext(d.Name()), ".md") {
return nil
}
rel, relErr := filepath.Rel(scopeRoot, path)
if relErr != nil {
return nil
}
segs := strings.Split(filepath.ToSlash(rel), "/")
typ := ""
if len(segs) >= 2 {
typ = strings.ToLower(segs[0])
}
if typ == "checkpoint" {
return nil
}
raw, readErr := os.ReadFile(path)
if readErr != nil {
if os.IsNotExist(readErr) {
return nil
}
return readErr
}
sum := sha256.Sum256(raw)
out = append(out, MemoryFile{
Path: filepath.Clean(path),
Scope: scope,
Type: typ,
Size: int64(len(raw)),
ContentHash: hex.EncodeToString(sum[:]),
Body: string(raw),
})
return nil
})
if err != nil {
return nil, err
}
// Deterministic order regardless of directory iteration order.
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
}
// dedupeGroups groups files by identical content hash, returning only groups
// with two or more members (a file with a unique hash is not a duplicate). The
// result is stable: groups are ordered by hash, paths within a group by path.
func dedupeGroups(files []MemoryFile) []DedupeGroup {
byHash := make(map[string][]string)
for _, f := range files {
byHash[f.ContentHash] = append(byHash[f.ContentHash], f.Path)
}
var groups []DedupeGroup
for hash, paths := range byHash {
if len(paths) < 2 {
continue
}
sort.Strings(paths)
groups = append(groups, DedupeGroup{Hash: hash, Paths: paths})
}
sort.Slice(groups, func(i, j int) bool { return groups[i].Hash < groups[j].Hash })
return groups
}
// invalidPathRefs scans each file's body for local filesystem path references
// and records the ones that no longer exist. Relative references are resolved
// against projectDir; "~/" against the user home dir. URLs and other external
// references are never considered (see extractLocalPathRefs).
func invalidPathRefs(files []MemoryFile, projectDir string) []InvalidPathRef {
var out []InvalidPathRef
for _, f := range files {
seen := make(map[string]struct{})
for _, ref := range extractLocalPathRefs(f.Body) {
if _, dup := seen[ref]; dup {
continue
}
seen[ref] = struct{}{}
// A bare relative reference is only meaningful against a project
// base. Without one (global-only plan) skip it rather than
// resolving against an arbitrary cwd and flagging spuriously.
isRelative := !filepath.IsAbs(ref) && !strings.HasPrefix(ref, "~/")
if isRelative && projectDir == "" {
continue
}
resolved := resolveRef(ref, projectDir)
if _, err := os.Stat(resolved); err != nil && os.IsNotExist(err) {
out = append(out, InvalidPathRef{File: f.Path, Ref: ref})
}
}
}
return out
}
// nearDupPairs emits candidate pairs whose normalized-token Jaccard similarity
// is at or above NearDupThreshold. Pairs of byte-identical files are skipped:
// those are exact duplicates handled by dedupeGroups, not near-duplicates.
func nearDupPairs(files []MemoryFile) []NearDupPair {
tokens := make([]map[string]struct{}, len(files))
for i, f := range files {
tokens[i] = tokenize(f.Body)
}
var out []NearDupPair
for i := 0; i < len(files); i++ {
for j := i + 1; j < len(files); j++ {
if files[i].ContentHash == files[j].ContentHash {
continue
}
sim := jaccard(tokens[i], tokens[j])
if sim >= NearDupThreshold {
out = append(out, NearDupPair{A: files[i].Path, B: files[j].Path, Similarity: sim})
}
}
}
return out
}
// projectID derives the stable projects-scope id from a project directory,
// mirroring internal/memory.resolveProjectId: the first 12 hex chars of
// sha256(absPath). The path is made absolute first so relative and absolute
// forms of the same directory map to the same id.
func projectID(projectDir string) string {
abs, err := filepath.Abs(projectDir)
if err != nil {
abs = projectDir
}
sum := sha256.Sum256([]byte(abs))
return hex.EncodeToString(sum[:])[:12]
}
// extractLocalPathRefs returns the local filesystem path references found in
// body. It splits on whitespace and common Markdown/code delimiters (so
// `path`, [text](path) and bare tokens are all captured), then keeps only
// tokens that look like a local path while rejecting URLs and other external
// references.
func extractLocalPathRefs(body string) []string {
fields := strings.FieldsFunc(body, func(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', '`', '(', ')', '[', ']', '{', '}', '"', '\'', '<', '>', ',', ';', '|':
return true
}
return false
})
var out []string
for _, tok := range fields {
// Trim trailing sentence punctuation that commonly abuts a path.
tok = strings.TrimRight(tok, ".:!?")
if isLocalPathRef(tok) {
out = append(out, tok)
}
}
return out
}
// isLocalPathRef reports whether tok looks like a local filesystem path rather
// than a URL or other external reference. It accepts absolute ("/..."),
// explicitly relative ("./", "../") and home-relative ("~/") tokens outright;
// a bare relative token (no leading marker) must contain a separator AND a file
// extension in its last segment, so ordinary prose like "TCP/IP", "read/write"
// or "and/or" is not mistaken for a path. URL-schemed tokens are rejected.
func isLocalPathRef(tok string) bool {
if tok == "" {
return false
}
if strings.Contains(tok, "://") {
return false
}
lower := strings.ToLower(tok)
for _, scheme := range []string{"http:", "https:", "ftp:", "ftps:", "file:", "mailto:", "ssh:", "git:", "www."} {
if strings.HasPrefix(lower, scheme) {
return false
}
}
switch {
case strings.HasPrefix(tok, "/"),
strings.HasPrefix(tok, "./"),
strings.HasPrefix(tok, "../"),
strings.HasPrefix(tok, "~/"):
return true
}
// A bare relative token needs both a separator and an extension on its last
// segment to be treated as a path (avoids flagging prose like "TCP/IP").
if !strings.Contains(tok, "/") {
return false
}
last := tok[strings.LastIndex(tok, "/")+1:]
return strings.Contains(last, ".") && !strings.HasSuffix(last, ".")
}
// resolveRef turns a reference into an absolute path for existence checking:
// "~/" expands to the user home dir, absolute paths pass through, and relative
// paths resolve against projectDir (or the current dir when projectDir is "").
func resolveRef(ref, projectDir string) string {
if strings.HasPrefix(ref, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ref[2:])
}
}
if filepath.IsAbs(ref) {
return filepath.Clean(ref)
}
return filepath.Join(projectDir, ref)
}
// tokenize lowercases body and splits it into the set of alphanumeric tokens
// used for near-duplicate similarity. Punctuation and Markdown syntax are
// discarded so formatting differences do not affect the score.
func tokenize(body string) map[string]struct{} {
set := make(map[string]struct{})
for _, tok := range strings.FieldsFunc(strings.ToLower(body), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
}) {
set[tok] = struct{}{}
}
return set
}
// jaccard returns |a∩b| / |ab|. Two empty sets are dissimilar (0) rather than
// identical, so blank files are never paired as near-duplicates.
func jaccard(a, b map[string]struct{}) float64 {
if len(a) == 0 || len(b) == 0 {
return 0
}
inter := 0
for t := range a {
if _, ok := b[t]; ok {
inter++
}
}
union := len(a) + len(b) - inter
if union == 0 {
return 0
}
return float64(inter) / float64(union)
}