From e4ad7bfbdc82585158ba86a63e15bf7b2e331a79 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:42:13 +0400 Subject: [PATCH] memory: ship rem CLI and branch-guarded knowledge-sync; sync installs memory tools --- README.md | 3 + cmd/dotagents/memory_tools.go | 104 ++++++++ cmd/dotagents/memory_tools_test.go | 72 ++++++ cmd/dotagents/sync.go | 8 + memory/tools/knowledge-sync/main.go | 26 +- memory/tools/rem/README.md | 21 ++ memory/tools/rem/dispatch.go | 52 ++++ memory/tools/rem/dream.go | 374 ++++++++++++++++++++++++++++ memory/tools/rem/dream_test.go | 144 +++++++++++ memory/tools/rem/go.mod | 3 + memory/tools/rem/main.go | 173 +++++++++++++ 11 files changed, 964 insertions(+), 16 deletions(-) create mode 100644 cmd/dotagents/memory_tools.go create mode 100644 cmd/dotagents/memory_tools_test.go create mode 100644 memory/tools/rem/README.md create mode 100644 memory/tools/rem/dispatch.go create mode 100644 memory/tools/rem/dream.go create mode 100644 memory/tools/rem/dream_test.go create mode 100644 memory/tools/rem/go.mod create mode 100644 memory/tools/rem/main.go diff --git a/README.md b/README.md index f6b9941..51bf8c7 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,9 @@ opencode: ## Hooks and memory +`memory/tools/` ships Go helper binaries for the memory system (currently `rem`, the capture/consolidation CLI, and `knowledge-sync`, the vault git sync). During `sync`, each tool is built and installed to `$GOBIN` or `~/.local/bin`; rebuilds happen only when sources change, and machines without a Go toolchain skip this step. + + Hooks are lifecycle commands (session start/end, stop) registered per harness in `dotagents.yaml`. dotagents ships a memory integration built on them — pick a tier during setup: ```bash diff --git a/cmd/dotagents/memory_tools.go b/cmd/dotagents/memory_tools.go new file mode 100644 index 0000000..c1c7d87 --- /dev/null +++ b/cmd/dotagents/memory_tools.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +// installMemoryTools builds every Go tool under /memory/tools/ +// (a directory with a go.mod) and installs the binary into the user's bin +// directory. This is how the default memory system (rem, knowledge-sync) +// follows a machine: dotagents sync provisions it after reconciling files. +// +// installed binary, so frequent syncs stay cheap. GOBIN overrides the +// destination directory; the default is $HOME/.local/bin. Requires the Go +// toolchain; without one this is a no-op so syncs never fail on plain hosts. +func installMemoryTools(repoRoot string) ([]string, error) { + if _, err := exec.LookPath("go"); err != nil { + fmt.Println("memory tools skipped: go toolchain not found in PATH") + return nil, nil + } + toolsDir := filepath.Join(repoRoot, "memory", "tools") + entries, err := os.ReadDir(toolsDir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", toolsDir, err) + } + + destDir := os.Getenv("GOBIN") + if destDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + destDir = filepath.Join(home, ".local", "bin") + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return nil, err + } + + var names []string + for _, entry := range entries { + if entry.IsDir() { + if _, err := os.Stat(filepath.Join(toolsDir, entry.Name(), "go.mod")); err == nil { + names = append(names, entry.Name()) + } + } + } + sort.Strings(names) + + var installed []string + for _, name := range names { + changed, err := buildMemoryTool(filepath.Join(toolsDir, name), filepath.Join(destDir, name)) + if err != nil { + return installed, fmt.Errorf("memory tool %s: %w", name, err) + } + if changed { + installed = append(installed, name) + } + } + return installed, nil +} + +func buildMemoryTool(srcDir, dest string) (bool, error) { + var newest time.Time + entries, err := os.ReadDir(srcDir) + if err != nil { + return false, err + } + for _, entry := range entries { + if entry.IsDir() || (!strings.HasSuffix(entry.Name(), ".go") && entry.Name() != "go.mod") { + continue + } + info, err := entry.Info() + if err != nil { + return false, err + } + if info.ModTime().After(newest) { + newest = info.ModTime() + } + } + if destInfo, err := os.Stat(dest); err == nil && !newest.After(destInfo.ModTime()) { + return false, nil + } + + tmpDest := dest + ".tmp" + cmd := exec.Command("go", "build", "-o", tmpDest, ".") + cmd.Dir = srcDir + if out, err := cmd.CombinedOutput(); err != nil { + os.Remove(tmpDest) + return false, fmt.Errorf("go build: %w (%s)", err, strings.TrimSpace(string(out))) + } + if err := os.Rename(tmpDest, dest); err != nil { + os.Remove(tmpDest) + return false, err + } + return true, nil +} diff --git a/cmd/dotagents/memory_tools_test.go b/cmd/dotagents/memory_tools_test.go new file mode 100644 index 0000000..4c43fad --- /dev/null +++ b/cmd/dotagents/memory_tools_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestInstallMemoryToolsBuildsAndSkipsFresh(t *testing.T) { + root := t.TempDir() + toolDir := filepath.Join(root, "memory", "tools", "hello") + if err := os.MkdirAll(toolDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(toolDir, "go.mod"), "module hello\n\ngo 1.24\n") + writeFile(t, filepath.Join(toolDir, "main.go"), "package main\n\nfunc main() {}\n") + + binDir := filepath.Join(t.TempDir(), "bin") + t.Setenv("GOBIN", binDir) + + installed, err := installMemoryTools(root) + if err != nil { + t.Fatalf("install: %v", err) + } + if len(installed) != 1 || installed[0] != "hello" { + t.Fatalf("installed = %v, want [hello]", installed) + } + if _, err := os.Stat(filepath.Join(binDir, "hello")); err != nil { + t.Fatalf("binary missing: %v", err) + } + + // Second run: binary is fresh, nothing rebuilt. + installed, err = installMemoryTools(root) + if err != nil { + t.Fatalf("second install: %v", err) + } + if len(installed) != 0 { + t.Fatalf("expected no rebuilds, got %v", installed) + } + + // Touch a source file; tool must rebuild. + future := filepath.Join(toolDir, "main.go") + if err := os.Chtimes(future, time.Now().Add(time.Minute), time.Now().Add(time.Minute)); err != nil { + t.Fatal(err) + } + installed, err = installMemoryTools(root) + if err != nil { + t.Fatalf("rebuild: %v", err) + } + if len(installed) != 1 { + t.Fatalf("expected rebuild after source change, got %v", installed) + } +} + +func TestInstallMemoryToolsMissingDirIsNoop(t *testing.T) { + t.Setenv("GOBIN", filepath.Join(t.TempDir(), "bin")) + installed, err := installMemoryTools(t.TempDir()) + if err != nil { + t.Fatalf("noop install: %v", err) + } + if installed != nil { + t.Fatalf("expected nil installs, got %v", installed) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/dotagents/sync.go b/cmd/dotagents/sync.go index 3379481..fd361fd 100644 --- a/cmd/dotagents/sync.go +++ b/cmd/dotagents/sync.go @@ -110,6 +110,14 @@ func runSync(opts runOptions) error { return err } + toolInstalls, err := installMemoryTools(repoRoot) + if err != nil { + return err + } + if len(toolInstalls) > 0 { + fmt.Printf("memory tools installed: %s\n", strings.Join(toolInstalls, ", ")) + } + repoReport, err = inspectRepoLink(repoRoot, home) if err != nil { return err diff --git a/memory/tools/knowledge-sync/main.go b/memory/tools/knowledge-sync/main.go index 58e4cc5..2a926c9 100644 --- a/memory/tools/knowledge-sync/main.go +++ b/memory/tools/knowledge-sync/main.go @@ -39,20 +39,12 @@ func main() { if err != nil { fatal("open lock", err, "") } - defer func() { - if err := lock.Close(); err != nil { - fmt.Fprintf(os.Stderr, "knowledge-sync: close lock failed: %v\n", err) - } - }() + defer func() { _ = lock.Close() }() if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { fmt.Println("knowledge-sync: another sync is running") return } - defer func() { - if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil { - fmt.Fprintf(os.Stderr, "knowledge-sync: unlock failed: %v\n", err) - } - }() + defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }() if out, err := git(repo, "status", "--short"); err != nil { fatal("git status", err, out) @@ -70,6 +62,12 @@ func main() { fmt.Print(out) } } + if out, err := git(repo, "rev-parse", "--abbrev-ref", "HEAD"); err != nil { + fatal("current branch", err, out) + } else if current := strings.TrimSpace(out); current != branch { + fatal("branch guard", fmt.Errorf("checkout is on %q, not %q", current, branch), + "knowledge-sync: refusing to sync: worktree checked out on "+current+", expected "+branch+"\n") + } if out, err := git(repo, "fetch", remote, branch); err != nil { fatal("git fetch", err, out) @@ -77,12 +75,8 @@ func main() { if out, err := git(repo, "merge", "--no-edit", remote+"/"+branch); err != nil { fmt.Print(out) conflict := "sync-conflict-" + time.Now().UTC().Format("20060102T150405Z") - if branchOut, branchErr := git(repo, "branch", conflict); branchErr != nil { - fatal("git conflict branch", branchErr, branchOut) - } - if abortOut, abortErr := git(repo, "merge", "--abort"); abortErr != nil { - fatal("git merge abort", abortErr, abortOut) - } + _, _ = git(repo, "branch", conflict) + _, _ = git(repo, "merge", "--abort") fatal("git merge", err, "created conflict branch "+conflict+"\n") } if out, err := git(repo, "push", remote, branch); err != nil { diff --git a/memory/tools/rem/README.md b/memory/tools/rem/README.md new file mode 100644 index 0000000..c70bc93 --- /dev/null +++ b/memory/tools/rem/README.md @@ -0,0 +1,21 @@ +# rem + +Memory workflow CLI for the knowledge vault. Shipped as a dotagents memory tool: +`dotagents sync` builds it and installs it to `$GOBIN` or `~/.local/bin`. + +``` +rem add [-src harness] "" capture a candidate fact to $KNOWLEDGE_DIR/ai/YYYY-MM-DD.md +rem search "" semantic search via memsearch (collection ai) +rem dream [--apply] consolidation report; --apply collapses exact-duplicate + sync sections in sessions/knowledge.md (backup + commit) +rem sync commit+merge+push the vault via knowledge-sync +``` + +Environment: + +- `KNOWLEDGE_DIR` - vault root (default `~/Workspace/knowledge`) +- `REM_SYNC_BIN` - alternate knowledge-sync binary for `rem sync` (default `~/.local/bin/knowledge-sync`) + +Design and rationale: `plans/rem-plan-2026-08.md` in the knowledge vault. + +Tests: `go test ./...` diff --git a/memory/tools/rem/dispatch.go b/memory/tools/rem/dispatch.go new file mode 100644 index 0000000..3397326 --- /dev/null +++ b/memory/tools/rem/dispatch.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "os" +) + +func fatal(step string, err error, out string) { + if out != "" { + fmt.Print(out) + } + fmt.Fprintf(os.Stderr, "rem: %s failed: %v\n", step, err) + os.Exit(1) +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + var err error + switch os.Args[1] { + case "add": + err = cmdAdd(os.Args[2:]) + case "search": + err = cmdSearch(os.Args[2:]) + case "sync": + err = cmdSync() + case "dream": + err = cmdDream(os.Args[2:]) + case "-h", "--help", "help": + usage() + default: + fmt.Fprintf(os.Stderr, "rem: unknown command %q\n", os.Args[1]) + usage() + os.Exit(2) + } + if err != nil { + fatal(os.Args[1], err, "") + } +} + +func usage() { + fmt.Fprint(os.Stderr, `rem - memory workflow for the knowledge vault + +usage: + rem add [-src harness] "" capture a candidate fact to ai/YYYY-MM-DD.md + rem search "" semantic search (memsearch, collection ai) + rem dream [--apply] consolidation report; --apply collapses exact dups + rem sync commit+merge+push via guarded knowledge-sync +`) +} diff --git a/memory/tools/rem/dream.go b/memory/tools/rem/dream.go new file mode 100644 index 0000000..e323103 --- /dev/null +++ b/memory/tools/rem/dream.go @@ -0,0 +1,374 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// ---- shared text helpers ------------------------------------------------- + +var syncHeadingRe = regexp.MustCompile(`(?m)^## Sync [^\n]+\n\n`) + +type syncEntry struct { + Section string // full "## Sync ..." section text, trimmed + Body string +} + +func splitSyncEntries(text string) (preamble string, entries []syncEntry) { + matches := syncHeadingRe.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return strings.TrimRight(text, "\n"), nil + } + preamble = strings.TrimRight(text[:matches[0][0]], "\n") + for i, m := range matches { + end := len(text) + if i+1 < len(matches) { + end = matches[i+1][0] + } + section := strings.TrimSpace(text[m[0]:end]) + entries = append(entries, syncEntry{Section: section, Body: strings.TrimSpace(text[m[1]:end])}) + } + return preamble, entries +} + +// collapseExactDuplicates keeps the first occurrence of each normalized body. +func collapseExactDuplicates(text string) (out string, dropped int) { + preamble, entries := splitSyncEntries(text) + seen := map[string]bool{} + var kept []string + for _, e := range entries { + fp := normalize(e.Body) + if seen[fp] { + dropped++ + continue + } + seen[fp] = true + kept = append(kept, e.Section) + } + out = preamble + if len(kept) > 0 { + out += "\n\n" + strings.Join(kept, "\n\n") + } + return out + "\n", dropped +} + +// ---- candidate clustering ------------------------------------------------- + +var negationWords = map[string]bool{ + "not": true, "never": true, "dont": true, "avoid": true, "stop": true, "no": true, +} + +type cluster struct { + Members []candidateLine + Distinct int // distinct source files + Negative int + Positive int + Fingerprint string +} + +func tokens(s string) map[string]bool { + t := map[string]bool{} + for _, w := range strings.Fields(normalize(s)) { + t[w] = true + } + return t +} + +func jaccard(a, b map[string]bool) float64 { + if len(a) == 0 || len(b) == 0 { + return 0 + } + inter := 0 + for w := range a { + if b[w] { + inter++ + } + } + union := len(a) + len(b) - inter + return float64(inter) / float64(union) +} + +const similarityThreshold = 0.6 + +// clusterCandidates groups candidates by lexical similarity; single-pass greedy, +// deterministic (candidates sorted by day then text). +func clusterCandidates(cands []candidateLine) []cluster { + sort.Slice(cands, func(i, j int) bool { + if cands[i].Day != cands[j].Day { + return cands[i].Day < cands[j].Day + } + return cands[i].Text < cands[j].Text + }) + var clusters []cluster + for _, c := range cands { + tk := tokens(c.Text) + neg := hasNegation(c.Text) + placed := false + for ci := range clusters { + rep := clusters[ci].Members[0] + if jaccard(tk, tokens(rep.Text)) >= similarityThreshold { + clusters[ci].Members = append(clusters[ci].Members, c) + if neg { + clusters[ci].Negative++ + } else { + clusters[ci].Positive++ + } + placed = true + break + } + } + if !placed { + nc := cluster{Members: []candidateLine{c}, Fingerprint: normalize(c.Text)} + if neg { + nc.Negative = 1 + } else { + nc.Positive = 1 + } + clusters = append(clusters, nc) + } + } + for ci := range clusters { + files := map[string]bool{} + for _, m := range clusters[ci].Members { + files[m.File] = true + } + clusters[ci].Distinct = len(files) + } + return clusters +} + +func hasNegation(s string) bool { + for w := range tokens(s) { + if negationWords[w] { + return true + } + } + return false +} + +// suggestTarget routes a cluster by simple cue words. Ambiguous -> needs review. +func suggestTarget(cl cluster) string { + text := "" + for _, m := range cl.Members { + text += " " + m.Text + } + t := tokens(text) + personal := false + operational := false + for w := range t { + switch w { + case "prefer", "prefers", "favorite", "my", "i": + personal = true + case "always", "repo", "repos", "commit", "commits", "agents", "harness", "skill", "skills": + operational = true + } + } + switch { + case personal && !operational: + return "profile/USER.md" + case operational && !personal: + return "~/.agents/AGENTS.md" + default: + return "needs review" + } +} + +// ---- dream command -------------------------------------------------------- + +func cmdDream(args []string) error { + apply := false + var rest []string + for _, a := range args { + if strings.TrimLeft(a, "-") == "apply" { + apply = true + continue + } + rest = append(rest, a) + } + if apply { + return dreamApply(rest) + } + return dreamReport(rest) +} + +func dreamReport([]string) error { + root := knowledgeDir() + cands, err := loadCandidates(filepath.Join(root, "ai")) + if err != nil { + return err + } + knowledgePath := filepath.Join(root, "sessions", "knowledge.md") + var dupCount, totalSections, distinctSections int + if data, err := os.ReadFile(knowledgePath); err == nil { + _, entries := splitSyncEntries(string(data)) + totalSections = len(entries) + seen := map[string]bool{} + for _, e := range entries { + if seen[normalize(e.Body)] { + continue + } + seen[normalize(e.Body)] = true + } + distinctSections = len(seen) + dupCount = totalSections - distinctSections + } + + clusters := clusterCandidates(cands) + repeaters := []cluster{} + for _, cl := range clusters { + if cl.Distinct >= 2 { + repeaters = append(repeaters, cl) + } + } + conflicts := []cluster{} + for _, cl := range repeaters { + if cl.Positive > 0 && cl.Negative > 0 { + conflicts = append(conflicts, cl) + } + } + + var b strings.Builder + fmt.Fprintf(&b, "# rem dream report %s\n\n", today()) + fmt.Fprintf(&b, "- candidates scanned: %d across %d ai/ files\n", len(cands), countAIDirs(root)) + fmt.Fprintf(&b, "- knowledge.md sync sections: %d (%d exact duplicates collapsible)\n", totalSections, dupCount) + fmt.Fprintf(&b, "- repeated clusters (>=2 distinct days/files): %d\n", len(repeaters)) + fmt.Fprintf(&b, "- conflicts (mixed polarity, not promotable): %d\n\n", len(conflicts)) + + if len(repeaters) == 0 { + b.WriteString("No promotion candidates this pass.\n") + } + for _, cl := range repeaters { + status := "propose" + target := suggestTarget(cl) + if cl.Positive > 0 && cl.Negative > 0 { + status = "conflict" + target = "needs review" + } + fmt.Fprintf(&b, "## Candidate: %s\n\n", truncate(cl.Members[0].Text, 80)) + fmt.Fprintf(&b, "- target: %s\n- status: %s\n- distinct sources: %d (positive %d / negative %d)\n- proposed bullet: %s\n- evidence:\n", + target, status, cl.Distinct, cl.Positive, cl.Negative, cl.Members[len(cl.Members)-1].Text) + seenFile := map[string]bool{} + for _, m := range cl.Members { + if seenFile[m.File] { + continue + } + seenFile[m.File] = true + fmt.Fprintf(&b, " - %s (%s): %s\n", m.Day, srcOr(m.Src), m.Text) + } + b.WriteString("\n") + } + if dupCount > 0 { + fmt.Fprintf(&b, "Run `rem dream --apply` to collapse the %d exact duplicate sync sections.\n", dupCount) + } + + outPath := filepath.Join(root, "reviews", "rem-dream-"+today()+".md") + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(outPath, []byte(b.String()), 0o644); err != nil { + return err + } + fmt.Printf("rem dream: %d candidates, %d repeaters, %d conflicts, %d collapsible dups\nreport: %s\n", + len(cands), len(repeaters), len(conflicts), dupCount, outPath) + return nil +} + +func srcOr(s string) string { + if s == "" { + return "manual" + } + return s +} + +func countAIDirs(root string) int { + entries, err := os.ReadDir(filepath.Join(root, "ai")) + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + n++ + } + } + return n +} + +func truncate(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) <= n { + return s + } + return s[:n-3] + "..." +} + +// dreamApply performs the only unattended-safe write: collapsing byte-normalized +// duplicate sync sections in sessions/knowledge.md, with backup + git commit. +func dreamApply([]string) error { + root := knowledgeDir() + path := filepath.Join(root, "sessions", "knowledge.md") + + // Refuse on dirty tree or non-main checkout. + if out, err := exec.Command("git", "-C", root, "status", "--porcelain").CombinedOutput(); err != nil { + return fmt.Errorf("git status: %w (%s)", err, out) + } else if strings.TrimSpace(string(out)) != "" { + return fmt.Errorf("refusing: knowledge repo has uncommitted changes") + } + if out, err := exec.Command("git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD").CombinedOutput(); err != nil { + return fmt.Errorf("git branch: %w (%s)", err, out) + } else if strings.TrimSpace(string(out)) != "main" { + return fmt.Errorf("refusing: worktree is on %s, expected main", strings.TrimSpace(string(out))) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + collapsed, dropped := collapseExactDuplicates(string(data)) + if dropped == 0 { + fmt.Println("rem dream --apply: nothing to collapse") + return nil + } + + // Keep backups invisible to the dirty-tree guard without touching tracked files. + if excl, err := os.ReadFile(filepath.Join(root, ".git", "info", "exclude")); err == nil && + !strings.Contains(string(excl), "knowledge.md.bak-") { + f, ferr := os.OpenFile(filepath.Join(root, ".git", "info", "exclude"), os.O_APPEND|os.O_WRONLY, 0o644) + if ferr == nil { + fmt.Fprintln(f, "sessions/knowledge.md.bak-*") + f.Close() + } + } + backup := path + ".bak-" + time.Now().UTC().Format("20060102T150405Z") + if err := os.WriteFile(backup, data, 0o644); err != nil { + return err + } + if err := os.WriteFile(path, []byte(collapsed), 0o644); err != nil { + return err + } + git := func(args ...string) error { + out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput() + if err != nil { + return fmt.Errorf("git %s: %w (%s)", args[0], err, out) + } + return nil + } + if err := git("add", "sessions/knowledge.md"); err != nil { + return err + } + msg := fmt.Sprintf("rem dream: collapse %d duplicate sync sections (backup %s)", + dropped, filepath.Base(backup)) + if out, err := exec.Command("git", "-C", root, "commit", "-m", msg).CombinedOutput(); err != nil { + if !strings.Contains(string(out), "nothing to commit") { + return fmt.Errorf("git commit: %w (%s)", err, out) + } + } + fmt.Printf("rem dream --apply: collapsed %d duplicate sections; backup %s\n", dropped, backup) + return nil +} diff --git a/memory/tools/rem/dream_test.go b/memory/tools/rem/dream_test.go new file mode 100644 index 0000000..6635366 --- /dev/null +++ b/memory/tools/rem/dream_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestNormalize(t *testing.T) { + cases := map[string]string{ + "Prefer pnpm for Node work.": "prefer pnpm for node work", + "Do NOT use cmux!": "do not use cmux", + } + for in, want := range cases { + if got := normalize(in); got != want { + t.Errorf("normalize(%q) = %q, want %q", in, got, want) + } + } +} + +func TestAddDedupesAcrossDays(t *testing.T) { + dir := t.TempDir() + t.Setenv("KNOWLEDGE_DIR", dir) + write(t, filepath.Join(dir, "ai", "2026-08-20.md"), "# 2026-08-20\n\n- candidate: Prefer pnpm for Node work (via claude)\n") + if err := cmdAdd([]string{"Prefer pnpm for Node work."}); err != nil { + t.Fatalf("add: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "ai", today()+".md")) + if data != nil { + t.Errorf("duplicate candidate written: %s", data) + } + if err := cmdAdd([]string{"-src", "codex", "Brand new fact"}); err != nil { + t.Fatalf("add new: %v", err) + } + data, _ = os.ReadFile(filepath.Join(dir, "ai", today()+".md")) + if !strings.Contains(string(data), "- candidate: Brand new fact (via codex)\n") { + t.Errorf("new candidate missing: %s", data) + } +} + +func TestCollapseExactDuplicatesKeepFirst(t *testing.T) { + in := "# Hermes Memory Export\n\nAuto-synced.\n\n## Sync 2026-05-01 00:00 UTC\n\n- unique fact A\n\n## Sync 2026-05-02 00:00 UTC\n\n- fact B\n- fact C\n\n## Sync 2026-05-03 00:00 UTC\n\n- FACT B!\n- fact C\n" + got, dropped := collapseExactDuplicates(in) + want := "# Hermes Memory Export\n\nAuto-synced.\n\n## Sync 2026-05-01 00:00 UTC\n\n- unique fact A\n\n## Sync 2026-05-02 00:00 UTC\n\n- fact B\n- fact C\n" + if dropped != 1 || got != want { + t.Errorf("dropped=%d got=%q want=%q", dropped, got, want) + } +} + +func TestClusterCandidatesRepeatsAndConflicts(t *testing.T) { + dir := filepath.Join(t.TempDir(), "ai") + write(t, filepath.Join(dir, "2026-08-01.md"), "# d\n\n- candidate: Do not recommend cctop because coverage is too narrow\n") + write(t, filepath.Join(dir, "2026-08-05.md"), "# d\n\n- candidate: do not always run gofmt before commit (via codex)\n") + write(t, filepath.Join(dir, "2026-08-07.md"), "# d\n\n- candidate: always run gofmt before commit\n") + cands, err := loadCandidates(dir) + if err != nil || len(cands) != 3 { + t.Fatalf("load: %v %d", err, len(cands)) + } + clusters := clusterCandidates(cands) + var repeated, conflicted int + for _, cl := range clusters { + if cl.Distinct >= 2 { + repeated++ + if cl.Positive > 0 && cl.Negative > 0 { + conflicted++ + } + } + } + if repeated != 1 || conflicted != 1 { + t.Errorf("repeated=%d conflicted=%d, want 1/1; clusters=%+v", repeated, conflicted, clusters) + } +} + +func TestDreamApplyCollapsesWithBackupAndGuards(t *testing.T) { + root := t.TempDir() + t.Setenv("KNOWLEDGE_DIR", root) + run := func(args ...string) (string, error) { + c := exec.Command("git", append([]string{"-C", root}, args...)...) + out, err := c.CombinedOutput() + return string(out), err + } + if out, err := run("init"); err != nil { + t.Fatalf("git init: %v %s", err, out) + } + run("config", "user.email", "t@t") + run("config", "user.name", "t") + write(t, filepath.Join(root, "sessions", "other.md"), "- clean\n") + kb := "# H\n\n## Sync 2026-05-01 00:00 UTC\n\n- fact A\n\n## Sync 2026-05-02 00:00 UTC\n\n- fact A\n" + write(t, filepath.Join(root, "sessions", "knowledge.md"), kb) + run("add", "-A") + run("commit", "-m", "init") + if _, err := run("branch", "-M", "main"); err != nil { + t.Fatalf("branch: %v", err) + } + + // Guard: uncommitted change blocks apply. + appendLine(t, filepath.Join(root, "sessions", "other.md"), "- dirty\n") + if err := dreamApply(nil); err == nil { + t.Error("apply should refuse on dirty tree") + } + run("reset", "--hard") + + if err := dreamApply(nil); err != nil { + t.Fatalf("apply: %v", err) + } + data, _ := os.ReadFile(filepath.Join(root, "sessions", "knowledge.md")) + if strings.Count(string(data), "## Sync") != 1 || !strings.Contains(string(data), "- fact A") { + t.Errorf("collapse wrong: %s", data) + } + matches, _ := filepath.Glob(filepath.Join(root, "sessions", "knowledge.md.bak-*")) + if len(matches) != 1 { + t.Errorf("expected one backup, got %v", matches) + } + out, _ := run("log", "--oneline") + if !strings.Contains(out, "rem dream: collapse 1 duplicate") { + t.Errorf("commit message missing: %s", out) + } + // Idempotent second run. + if err := dreamApply(nil); err != nil { + t.Fatalf("second apply: %v", err) + } +} + +func appendLine(t *testing.T, path, line string) { + t.Helper() + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + f.WriteString(line + "\n") + f.Close() +} diff --git a/memory/tools/rem/go.mod b/memory/tools/rem/go.mod new file mode 100644 index 0000000..b7632c5 --- /dev/null +++ b/memory/tools/rem/go.mod @@ -0,0 +1,3 @@ +module rem + +go 1.26.1 diff --git a/memory/tools/rem/main.go b/memory/tools/rem/main.go new file mode 100644 index 0000000..a8d4c36 --- /dev/null +++ b/memory/tools/rem/main.go @@ -0,0 +1,173 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// candidateLine is a parsed capture line from ai/*.md. +type candidateLine struct { + Text string // fact text, provenance suffix stripped + Src string // provenance, e.g. "claude", "codex"; empty if none + File string // path of the ai/ file it came from + Day string // YYYY-MM-DD derived from filename +} + +var candidateRe = regexp.MustCompile(`^- candidate: (.*?)(?: \(via ([^)]+)\))?$`) + +// normalize lowercases, strips punctuation, and collapses whitespace. +func normalize(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = regexp.MustCompile(`[^a-z0-9+]+`).ReplaceAllString(s, " ") + return strings.Join(strings.Fields(s), " ") +} + +func knowledgeDir() string { + if d := os.Getenv("KNOWLEDGE_DIR"); d != "" { + return d + } + home, err := os.UserHomeDir() + if err != nil { + fatal("user home", err, "") + } + return filepath.Join(home, "Workspace", "knowledge") +} + +func today() string { return time.Now().Format("2006-01-02") } + +// cmdAdd appends `- candidate: ` to ai/YYYY-MM-DD.md, skipping facts already +// captured as candidates in any ai/ file. +func cmdAdd(args []string) error { + src := "" + var rest []string + for i := 0; i < len(args); i++ { + if args[i] == "-src" && i+1 < len(args) { + src = args[i+1] + i++ + continue + } + rest = append(rest, args[i]) + } + fact := strings.TrimSpace(strings.Join(rest, " ")) + if fact == "" { + return fmt.Errorf("usage: rem add [-src harness] \"fact\"") + } + + dir := filepath.Join(knowledgeDir(), "ai") + existing, err := loadCandidates(dir) + if err != nil { + return err + } + fp := normalize(fact) + for _, c := range existing { + if normalize(c.Text) == fp { + fmt.Printf("rem add: already captured on %s: %s\n", c.Day, c.Text) + return nil + } + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, today()+".md") + fresh := false + if _, err := os.Stat(path); os.IsNotExist(err) { + fresh = true + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if fresh { + if _, err := fmt.Fprintf(f, "# %s\n\n", today()); err != nil { + return err + } + } + line := "- candidate: " + fact + if src != "" { + line += " (via " + src + ")" + } + if _, err := fmt.Fprintln(f, line); err != nil { + return err + } + fmt.Printf("rem add: captured to %s\n", path) + return nil +} + +// loadCandidates parses every `- candidate:` line across ai/*.md. +func loadCandidates(dir string) ([]candidateLine, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + var out []candidateLine + for _, name := range names { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(line, " \t\r") + m := candidateRe.FindStringSubmatch(line) + if m == nil { + continue + } + out = append(out, candidateLine{ + Text: strings.TrimSpace(m[1]), + Src: m[2], + File: filepath.Join(dir, name), + Day: strings.TrimSuffix(name, ".md"), + }) + } + } + return out, nil +} + +// cmdSearch passes a query through to memsearch. +func cmdSearch(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: rem search \"query\"") + } + bin, err := exec.LookPath("memsearch") + if err != nil { + return fmt.Errorf("memsearch not found: %w", err) + } + cmd := exec.Command(bin, append([]string{"search"}, args...)...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +} + +// cmdSync wraps the guarded knowledge-sync binary. +func cmdSync() error { + bin := os.Getenv("REM_SYNC_BIN") + if bin == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + bin = filepath.Join(home, ".local", "bin", "knowledge-sync") + } + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("knowledge-sync not found at %s: %w", bin, err) + } + cmd := exec.Command(bin) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +}