-
Notifications
You must be signed in to change notification settings - Fork 0
memory: ship rem CLI; sync installs memory tools #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "sort" | ||
| "strings" | ||
| "time" | ||
| ) | ||
|
|
||
| // installMemoryTools builds every Go tool under <repoRoot>/memory/tools/<name> | ||
| // (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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,19 +62,21 @@ 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") | ||
|
Comment on lines
+66
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (broader_impact): The branch guard runs after the existing dirty-tree handling has staged and committed all changes. A dirty worktree on the wrong branch is therefore committed to that wrong branch before the command refuses to fetch, merge, or push, directly violating the guard's purpose. Triggers: When the vault worktree is dirty and checked out on a branch other than Suggested fix: Validate the current branch before the status/add/commit block. |
||
| } | ||
|
|
||
| if out, err := git(repo, "fetch", remote, branch); err != nil { | ||
| fatal("git fetch", err, out) | ||
| } | ||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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] "<fact>" capture a candidate fact to $KNOWLEDGE_DIR/ai/YYYY-MM-DD.md | ||
| rem search "<query>" 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 ./...` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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] "<fact>" capture a candidate fact to ai/YYYY-MM-DD.md | ||
| rem search "<query>" semantic search (memsearch, collection ai) | ||
| rem dream [--apply] consolidation report; --apply collapses exact dups | ||
| rem sync commit+merge+push via guarded knowledge-sync | ||
| `) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (bug_risk): The freshness scan ignores
.gofiles in subdirectories, so changing an imported local package does not advancenewest; an existing binary is then treated as fresh and the installer skips rebuilding it.Triggers: When a memory tool has Go source in a nested package directory and that source changes.
Suggested fix: Walk the module recursively, or use Go's package/build metadata to determine whether any source dependency is newer than the installed binary.