Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions cmd/dotagents/memory_tools.go
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()
}
}
Comment on lines +72 to +87

Copy link
Copy Markdown

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 .go files in subdirectories, so changing an imported local package does not advance newest; 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.

Suggested change
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()
}
}
err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || (!strings.HasSuffix(info.Name(), ".go") && info.Name() != "go.mod") {
return nil
}
if info.ModTime().After(newest) {
newest = info.ModTime()
}
return nil
})
if err != nil {
return false, err
}

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
}
72 changes: 72 additions & 0 deletions cmd/dotagents/memory_tools_test.go
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)
}
}
8 changes: 8 additions & 0 deletions cmd/dotagents/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 10 additions & 16 deletions memory/tools/knowledge-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 KNOWLEDGE_BRANCH.

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 {
Expand Down
21 changes: 21 additions & 0 deletions memory/tools/rem/README.md
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 ./...`
52 changes: 52 additions & 0 deletions memory/tools/rem/dispatch.go
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
`)
}
Loading
Loading