openvibers/openvibe.link Commits

memory sync: write the profile and project rules into the tools' own files (#113)

OP OpenVibers committed 7 days ago · e222de11c0 · 9 files, +1693 −2

* memory sync: write the profile and project rules into the tools' own files

Opt-in per tool, off by default. AGENTS.md first, a thin CLAUDE.md that imports it, GEMINI.md, Cursor rules and Copilot instructions, each inside one marked block (<!-- openvibe-memory:start --> ... end). The first write shows a diff and asks; --dry-run writes nothing; --disable and --undo remove only the block (and a file the Node created). The memory text is scrubbed of the markers, and the state file holds paths and choices, never text.

* memorysync: skip the 0600 assertion on Windows

Windows does not keep POSIX permission bits, so the state round-trip test sees 0666. The state is still created with Chmod(0600); the test only checks it where the OS records it.

Changes

ddiff --git a/README.md b/README.md
iindex 61852fe..3d02deb 100644
---- a/README.md
++++ b/README.md
@@@ -42,6 +42,7 @@ openvibe-node approvals # the computer plugin at level ask: what waits; approve
openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)
openvibe-node memory scan # what your coding tools remember: read-only, local, secrets removed
openvibe-node memory upload # send what they remember to your own memory store, after you say yes
++openvibe-node memory sync # write your profile and rules back into the tools, one marked block per file, opt-in per tool
```
## What is in here
ddiff --git a/cmd/openvibe-node/memory.go b/cmd/openvibe-node/memory.go
iindex dc4d25a..81beddc 100644
---- a/cmd/openvibe-node/memory.go
++++ b/cmd/openvibe-node/memory.go
@@@ -15,6 +15,7 @@ import (
const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--json]\n" +
"openvibe-node memory upload [--tools a,b] [--project DIR …] [--dry-run] [--yes]\n" +
"openvibe-node memory status [--show]\n" +
++ "openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes] | --disable a,b | --undo\n" +
"\n" +
" scan reads the memory files of the coding tools on this machine and\n" +
" prints what it found. It is read-only and local: nothing is written\n" +
@@@ -23,6 +24,10 @@ const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--
" upload sends the items to your own memory store (OpenVibe.AI). It shows\n" +
" what would leave the machine and asks before it sends anything.\n" +
" status shows what your own memory store holds.\n" +
++ " sync writes your profile and project rules into AGENTS.md, a thin\n" +
++ " CLAUDE.md, GEMINI.md, Cursor rules and Copilot instructions,\n" +
++ " inside one marked block per file, after showing the diff. Each\n" +
++ " tool is off until you turn it on with --tools.\n" +
"\n" +
" --tools a,b only these adapters (claude-code, agents, gemini, cursor,\n" +
" copilot, windsurf, cline, roo, kiro, continue, aider,\n" +
@@@ -60,6 +65,8 @@ func cmdMemory(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return cmdMemoryUpload(args[1:], stdin, stdout, stderr)
case "status":
return cmdMemoryStatus(args[1:], stdout, stderr)
++ case "sync":
++ return cmdMemorySync(args[1:], stdin, stdout, stderr)
case "scan":
default:
fmt.Fprint(stderr, memoryUsage)
ddiff --git a/cmd/openvibe-node/memory_sync.go b/cmd/openvibe-node/memory_sync.go
nnew file mode 100644
iindex 0000000..9b0da33
---- /dev/null
++++ b/cmd/openvibe-node/memory_sync.go
@@@ -0,0 +1,531 @@
++package main
++
++import (
++ "bufio"
++ "context"
++ "errors"
++ "flag"
++ "fmt"
++ "io"
++ "os"
++ "path/filepath"
++ "sort"
++ "strings"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/memorystore"
++ "github.com/OpenVibers/OpenVibe.Node/internal/memorysync"
++ "github.com/OpenVibers/OpenVibe.Node/internal/memoryupload"
++)
++
++// memorySyncUsage is what "openvibe-node memory sync" prints.
++const memorySyncUsage = "openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]\n" +
++ "openvibe-node memory sync --disable a,b\n" +
++ "openvibe-node memory sync --undo\n" +
++ "\n" +
++ " Writes your OpenVibe Memory profile and this project's rules into the\n" +
++ " places the coding tools read them: AGENTS.md (Codex and friends), a thin\n" +
++ " CLAUDE.md that imports it, GEMINI.md, Cursor rules and Copilot\n" +
++ " instructions. It writes only inside its own marked block; your own text\n" +
++ " in those files is never changed.\n" +
++ "\n" +
++ " Every tool is off until you turn it on. The first write to a file shows\n" +
++ " the diff and asks. --disable takes the block out again and turns the tool\n" +
++ " off; --undo does that for every tool that is on.\n" +
++ "\n" +
++ " --tools a,b turn these tools on: agents, claude-code, gemini, cursor,\n" +
++ " copilot (with no --tools, the tools already on are synced)\n" +
++ " --disable a,b turn these tools off and remove their blocks\n" +
++ " --undo remove every block this command wrote and turn the tools off\n" +
++ " --project DIR write this project's files as well; may be given more than once\n" +
++ " --dry-run show the diffs and write nothing\n" +
++ " --yes do not ask on the first write\n" +
++ " --state FILE the opt-in file (default: the Node's state directory)\n" +
++ " --endpoint URL the memory store (default " + memorystore.DefaultBaseURL + ")\n" +
++ " --token-env NAME the variable the access token is read from (default " + memorystore.DefaultTokenEnv + ")\n" +
++ " --user-home DIR use this directory as the person's home (default: yours)\n"
++
++// cmdMemorySync writes the person's memory into the tools' own files, one
++// marked block at a time.
++func cmdMemorySync(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
++ fs := flag.NewFlagSet("memory sync", flag.ContinueOnError)
++ fs.SetOutput(stderr)
++ fs.Usage = func() { fmt.Fprint(stderr, memorySyncUsage) }
++ var tools stringList
++ var disable stringList
++ var projectFlags stringList
++ fs.Var(&tools, "tools", "turn these tools on")
++ fs.Var(&disable, "disable", "turn these tools off and remove their blocks")
++ fs.Var(&projectFlags, "project", "write this project as well")
++ dryRun := fs.Bool("dry-run", false, "show the diffs and write nothing")
++ assumeYes := fs.Bool("yes", false, "do not ask on the first write")
++ undo := fs.Bool("undo", false, "remove every block and turn the tools off")
++ stateFile := fs.String("state", "", "the opt-in file")
++ endpoint := fs.String("endpoint", memorystore.DefaultBaseURL, "the memory store")
++ tokenEnv := fs.String("token-env", memorystore.DefaultTokenEnv, "the variable the access token is read from")
++ userHome := fs.String("user-home", "", "use this directory as the person's home")
++ if err := fs.Parse(args); err != nil {
++ return 2
++ }
++ if fs.NArg() > 0 {
++ fmt.Fprintf(stderr, "unknown argument %q\n\n", fs.Arg(0))
++ fmt.Fprint(stderr, memorySyncUsage)
++ return 2
++ }
++ for _, id := range append(append([]string{}, tools...), disable...) {
++ if !memorysync.KnownTool(id) {
++ fmt.Fprintf(stderr, "error: no such tool %q. The tools are: %s\n", id, strings.Join(memorysync.ToolIDs(), ", "))
++ return 2
++ }
++ }
++ if *undo && len(tools) > 0 {
++ fmt.Fprintln(stderr, "error: --undo and --tools are not used together; --undo removes every block")
++ return 2
++ }
++ if len(tools) > 0 && len(disable) > 0 {
++ fmt.Fprintln(stderr, "error: --tools and --disable are not used together; run them in two steps")
++ return 2
++ }
++ home, code := memoryHome(*userHome, stderr)
++ if code != 0 {
++ return code
++ }
++ statePath := *stateFile
++ if statePath == "" {
++ statePath = filepath.Join(config.DefaultPaths("").StateDir, "memory-sync.json")
++ }
++ state, err := memorysync.LoadState(statePath)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: the sync state %s cannot be read: %v\n", statePath, err)
++ return 1
++ }
++ projects, err := absProjects(projectFlags)
++ if err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++
++ if *undo || len(disable) > 0 {
++ turnOff := disable
++ if *undo {
++ turnOff = state.EnabledTools()
++ if len(turnOff) == 0 {
++ fmt.Fprintln(stdout, "No tool is on, so there is no block to remove.")
++ return 0
++ }
++ }
++ return syncRemove(home, projects, state, statePath, turnOff, *dryRun, stdout, stderr)
++ }
++
++ // With no --tools, sync what is already on. Nothing is on by default.
++ on := state.EnabledTools()
++ if len(tools) > 0 {
++ on = append([]string{}, tools...)
++ }
++ if len(on) == 0 {
++ fmt.Fprintln(stdout, "No tool is on: nothing was written.")
++ fmt.Fprintf(stdout, "Turn one on with --tools (%s). The first write shows its diff and asks.\n", strings.Join(memorysync.ToolIDs(), ", "))
++ return 0
++ }
++ sort.Strings(on)
++
++ content, err := fetchContent(projects, on, *endpoint, *tokenEnv)
++ if err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ return syncWrite(home, projects, state, statePath, on, content, *dryRun, *assumeYes, stdin, stdout, stderr)
++}
++
++// absProjects makes the project paths absolute and checks they are directories.
++func absProjects(projects []string) ([]string, error) {
++ out := make([]string, 0, len(projects))
++ seen := map[string]bool{}
++ for _, p := range projects {
++ abs, err := filepath.Abs(p)
++ if err != nil {
++ return nil, err
++ }
++ info, err := os.Stat(abs)
++ if err != nil || !info.IsDir() {
++ return nil, fmt.Errorf("%s is not a directory", p)
++ }
++ if !seen[abs] {
++ seen[abs] = true
++ out = append(out, abs)
++ }
++ }
++ return out, nil
++}
++
++// fetchContent reads the profile of the person and of each project, and the
++// project rules of each project, from the person's own store.
++func fetchContent(projects []string, tools []string, endpoint, tokenEnv string) (map[string]memorysync.Content, error) {
++ token := strings.TrimSpace(os.Getenv(tokenEnv))
++ if token == "" {
++ return nil, fmt.Errorf("%s is not set: it must hold your OpenVibe access token", tokenEnv)
++ }
++ client, err := memorystore.New(memorystore.Options{BaseURL: endpoint, Token: token})
++ if err != nil {
++ return nil, err
++ }
++ ctx := context.Background()
++ out := map[string]memorysync.Content{}
++ user, err := client.Profile(ctx, "user")
++ if err != nil {
++ return nil, err
++ }
++ out["user"] = memorysync.Content{Scope: "user", Profile: user.Text}
++ for _, project := range projects {
++ slug := memoryupload.Slug(project)
++ if slug == "" {
++ return nil, fmt.Errorf("the folder %s has no name the store can use", project)
++ }
++ scope := "project:" + slug
++ profile, err := client.Profile(ctx, scope)
++ if err != nil {
++ return nil, err
++ }
++ items, err := client.List(ctx, scope, "")
++ if err != nil {
++ return nil, err
++ }
++ rules := []string{}
++ for _, item := range items.Items {
++ if item.Status != "" && item.Status != "active" {
++ continue
++ }
++ if item.Kind == "project_rule" {
++ rules = append(rules, item.Text)
++ }
++ }
++ out[scope] = memorysync.Content{Scope: scope, Profile: profile.Text, Rules: rules}
++ }
++ // A tool that only has project files needs a project to write into.
++ for _, id := range tools {
++ if id != "cursor" && id != "copilot" {
++ continue
++ }
++ if len(projects) == 0 {
++ return nil, fmt.Errorf("%s writes only project files: give at least one --project", id)
++ }
++ }
++ return out, nil
++}
++
++// plannedFile is one file the sync is about to look at.
++type plannedFile struct {
++ target memorysync.Target
++ path string
++ scope string
++}
++
++// filesFor gives every file of the given tools, for the home and the projects.
++func filesFor(home string, projects []string, tools []string) []plannedFile {
++ on := map[string]bool{}
++ for _, id := range tools {
++ on[id] = true
++ }
++ out := []plannedFile{}
++ for _, target := range memorysync.Targets() {
++ if !on[target.Tool] {
++ continue
++ }
++ if target.Root == memorysync.RootHome {
++ out = append(out, plannedFile{target: target, path: filepath.Join(home, filepath.FromSlash(target.Rel)), scope: "user"})
++ continue
++ }
++ for _, project := range projects {
++ out = append(out, plannedFile{
++ target: target,
++ path: filepath.Join(project, filepath.FromSlash(target.Rel)),
++ scope: "project:" + memoryupload.Slug(project),
++ })
++ }
++ }
++ return out
++}
++
++// syncWrite shows the diff of every changed file and writes it inside the
++// marked block, after the first-write gate.
++func syncWrite(home string, projects []string, state *memorysync.State, statePath string, tools []string,
++ content map[string]memorysync.Content, dryRun, assumeYes bool, stdin io.Reader, stdout, stderr io.Writer) int {
++ fmt.Fprintf(stdout, "Memory sync of %s\n", home)
++ if len(projects) == 0 {
++ fmt.Fprintln(stdout, "No project given: only the user-level files are written.")
++ } else {
++ fmt.Fprintf(stdout, "Projects: %s\n", strings.Join(projects, ", "))
++ }
++ fmt.Fprintf(stdout, "Tools on: %s\n", strings.Join(tools, ", "))
++
++ written, skipped, unchanged := 0, 0, 0
++ reader := bufio.NewReader(stdin)
++ for _, planned := range filesFor(home, projects, tools) {
++ c, ok := content[planned.scope]
++ if !ok {
++ fmt.Fprintf(stderr, "error: no memory was read for %s\n", planned.scope)
++ return 1
++ }
++ body := memorysync.Body(c)
++ if planned.target.Import != nil {
++ body = memorysync.ImportBody(planned.target.Import(home))
++ }
++ block := memorysync.Block(body)
++ existing, existed, err := readFile(planned.path)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ return 1
++ }
++ var next []byte
++ var sep string
++ if block == "" {
++ fileState := state.FileStateOf(planned.target.Tool, planned.path)
++ oldSep := ""
++ if fileState != nil {
++ oldSep = fileState.Sep
++ }
++ next, _, err = memorysync.Remove(existing, oldSep)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %s: %v\n", displayPath(home, planned.path), err)
++ skipped++
++ continue
++ }
++ } else {
++ if !existed && !planned.target.Create && planned.target.Frontmatter == "" {
++ // Files that are not ours to create stay absent.
++ fmt.Fprintf(stdout, "\n%s: not there; not creating it (only its own block is ever written)\n", displayPath(home, planned.path))
++ skipped++
++ continue
++ }
++ if !existed && planned.target.Frontmatter != "" {
++ existing = []byte(planned.target.Frontmatter)
++ }
++ next, sep, _, err = memorysync.Replace(existing, block)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %s: %v\n", displayPath(home, planned.path), err)
++ skipped++
++ continue
++ }
++ }
++ if string(next) == string(existing) {
++ unchanged++
++ continue
++ }
++ fmt.Fprintf(stdout, "\n%s\n", displayPath(home, planned.path))
++ if planned.target.Root == memorysync.RootProject {
++ fmt.Fprintln(stdout, " (this is a project file: it may be shared or committed)")
++ }
++ fmt.Fprint(stdout, memorysync.Diff(displayPath(home, planned.path), existing, next))
++ if dryRun {
++ skipped++
++ continue
++ }
++ fileState := state.File(planned.target.Tool, planned.path)
++ if !fileState.Confirmed {
++ if !assumeYes && !ask(reader, stdout, "Write this file for the first time? [y/N] ") {
++ fmt.Fprintln(stdout, "Not written: no confirmation.")
++ skipped++
++ continue
++ }
++ fileState.Confirmed = true
++ }
++ if err := writeFile(planned.path, next); err != nil {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ return 1
++ }
++ if !existed {
++ fileState.Created = true
++ }
++ if sep != "" {
++ fileState.Sep = sep
++ }
++ if err := state.Save(statePath); err != nil {
++ fmt.Fprintf(stderr, "error: the sync state %s cannot be saved: %v\n", statePath, err)
++ return 1
++ }
++ fmt.Fprintln(stdout, "Written, inside the OpenVibe Memory block.")
++ written++
++ }
++
++ if !dryRun {
++ for _, id := range tools {
++ state.Tool(id).Enabled = true
++ }
++ if err := state.Save(statePath); err != nil {
++ fmt.Fprintf(stderr, "error: the sync state %s cannot be saved: %v\n", statePath, err)
++ return 1
++ }
++ }
++ fmt.Fprintf(stdout, "\n%d file(s) written, %d unchanged, %d not written.\n", written, unchanged, skipped)
++ if dryRun {
++ fmt.Fprintln(stdout, "Nothing was written: this was a dry run.")
++ }
++ return 0
++}
++
++// syncRemove takes the block out of every file of the given tools and turns
++// them off.
++func syncRemove(home string, projects []string, state *memorysync.State, statePath string, tools []string,
++ dryRun bool, stdout, stderr io.Writer) int {
++ fmt.Fprintf(stdout, "Memory sync undo of %s\n", home)
++ removed, missing, failed := 0, 0, 0
++ for _, id := range tools {
++ // The state knows every file that was written; the targets catch a
++ // file that was edited by hand after the state was lost.
++ paths := map[string]memorysync.Target{}
++ for _, planned := range filesFor(home, projects, []string{id}) {
++ paths[planned.path] = planned.target
++ }
++ if t := state.Tools[id]; t != nil {
++ for path := range t.Files {
++ if _, ok := paths[path]; !ok {
++ if target, ok := memorysync.TargetFor(path); ok {
++ paths[path] = target
++ } else {
++ paths[path] = memorysync.Target{Tool: id, Name: id}
++ }
++ }
++ }
++ }
++ for path, target := range paths {
++ existing, existed, err := readFile(path)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ failed++
++ continue
++ }
++ if !existed {
++ missing++
++ continue
++ }
++ fileState := state.FileStateOf(id, path)
++ sep := ""
++ created := false
++ if fileState != nil {
++ sep = fileState.Sep
++ created = fileState.Created
++ }
++ next, found, err := memorysync.Remove(existing, sep)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %s: %v\n", path, err)
++ failed++
++ continue
++ }
++ if !found {
++ missing++
++ if fileState != nil && !dryRun {
++ delete(state.Tool(id).Files, path)
++ }
++ continue
++ }
++ fmt.Fprintf(stdout, "\n%s: removed the OpenVibe Memory block\n", displayPath(home, path))
++ if dryRun {
++ removed++
++ continue
++ }
++ if created && memorysync.OnlyOurContent(next, target.Frontmatter) {
++ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ failed++
++ continue
++ }
++ fmt.Fprintf(stdout, "%s: removed (the Node created it)\n", displayPath(home, path))
++ } else if err := writeFile(path, next); err != nil {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ failed++
++ continue
++ }
++ delete(state.Tool(id).Files, path)
++ removed++
++ }
++ if !dryRun {
++ state.Tool(id).Enabled = false
++ }
++ }
++ if !dryRun {
++ if err := state.Save(statePath); err != nil {
++ fmt.Fprintf(stderr, "error: the sync state %s cannot be saved: %v\n", statePath, err)
++ return 1
++ }
++ }
++ fmt.Fprintf(stdout, "\n%d block(s) removed, %d file(s) had none, %d failed.\n", removed, missing, failed)
++ if dryRun {
++ fmt.Fprintln(stdout, "Nothing was changed: this was a dry run.")
++ }
++ if failed > 0 {
++ return 1
++ }
++ return 0
++}
++
++// readFile reads a file, treating a missing file as empty. It never follows a
++// path that is not a regular file.
++func readFile(path string) ([]byte, bool, error) {
++ info, err := os.Lstat(path)
++ if errors.Is(err, os.ErrNotExist) {
++ return nil, false, nil
++ }
++ if err != nil {
++ return nil, false, err
++ }
++ if !info.Mode().IsRegular() {
++ return nil, false, fmt.Errorf("%s is not a regular file; leaving it alone", path)
++ }
++ raw, err := os.ReadFile(path)
++ if err != nil {
++ return nil, false, err
++ }
++ return raw, true, nil
++}
++
++// writeFile writes the file through a temporary file in its own directory, so a
++// crash never leaves half a block.
++func writeFile(path string, data []byte) error {
++ dir := filepath.Dir(path)
++ if err := os.MkdirAll(dir, 0o755); err != nil {
++ return err
++ }
++ mode := os.FileMode(0o644)
++ if info, err := os.Stat(path); err == nil {
++ mode = info.Mode().Perm()
++ }
++ tmp, err := os.CreateTemp(dir, ".openvibe-memory-")
++ if err != nil {
++ return err
++ }
++ name := tmp.Name()
++ defer os.Remove(name)
++ if err := tmp.Chmod(mode); err != nil {
++ tmp.Close()
++ return err
++ }
++ if _, err := tmp.Write(data); err != nil {
++ tmp.Close()
++ return err
++ }
++ if err := tmp.Close(); err != nil {
++ return err
++ }
++ return os.Rename(name, path)
++}
++
++// ask shows the question and takes yes for an answer. Anything else is no.
++func ask(reader *bufio.Reader, stdout io.Writer, question string) bool {
++ fmt.Fprint(stdout, "\n"+question)
++ line, err := reader.ReadString('\n')
++ if err != nil && line == "" {
++ fmt.Fprintln(stdout)
++ return false
++ }
++ answer := strings.ToLower(strings.TrimSpace(line))
++ return answer == "y" || answer == "yes"
++}
++
++// displayPath shows a file without the absolute project path in the output: a
++// home file is `~/.codex/AGENTS.md`, a project file is relative to its project.
++func displayPath(home, path string) string {
++ if rel, err := filepath.Rel(home, path); err == nil && !strings.HasPrefix(rel, "..") {
++ return "~/" + filepath.ToSlash(rel)
++ }
++ return path
++}
ddiff --git a/cmd/openvibe-node/memory_sync_test.go b/cmd/openvibe-node/memory_sync_test.go
nnew file mode 100644
iindex 0000000..54e0268
---- /dev/null
++++ b/cmd/openvibe-node/memory_sync_test.go
@@@ -0,0 +1,345 @@
++package main
++
++import (
++ "bytes"
++ "encoding/json"
++ "io"
++ "net/http"
++ "net/http/httptest"
++ "os"
++ "path/filepath"
++ "strings"
++ "sync"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/memorystore"
++ "github.com/OpenVibers/OpenVibe.Node/internal/memoryupload"
++)
++
++// syncStore is a fake memory store: one profile per scope and one list of
++// items per scope, with every request recorded.
++type syncStore struct {
++ mu sync.Mutex
++ requests []string
++ profiles map[string]string
++ items map[string][]memorystore.ListItem
++ profileFailure int
++}
++
++func newSyncStore(t *testing.T) (*syncStore, *httptest.Server) {
++ t.Helper()
++ t.Setenv("OPENVIBE_TOKEN", "the-person-token")
++ ms := &syncStore{
++ profiles: map[string]string{"user": "I prefer short answers."},
++ items: map[string][]memorystore.ListItem{},
++ }
++ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ ms.mu.Lock()
++ ms.requests = append(ms.requests, r.URL.Path+"?"+r.URL.RawQuery)
++ ms.mu.Unlock()
++ w.Header().Set("Content-Type", "application/json")
++ switch r.URL.Path {
++ case "/api/v1/memory/profile":
++ if ms.profileFailure != 0 {
++ w.WriteHeader(ms.profileFailure)
++ _, _ = io.WriteString(w, `{"code":"memory.unavailable","detail":"the store is not configured"}`)
++ return
++ }
++ scope := r.URL.Query().Get("scope")
++ if scope == "" {
++ scope = "user"
++ }
++ text := ms.profiles[scope]
++ if text == "" && scope != "user" {
++ text = "This project is a demo project."
++ }
++ _ = json.NewEncoder(w).Encode(map[string]any{
++ "profile": map[string]any{"scope": scope, "text": text, "built_at": "2026-11-01T00:00:00Z"},
++ })
++ case "/api/v1/memory/items":
++ scope := r.URL.Query().Get("scope")
++ _ = json.NewEncoder(w).Encode(map[string]any{"items": ms.items[scope], "count": len(ms.items[scope])})
++ default:
++ w.WriteHeader(404)
++ _, _ = io.WriteString(w, `{"code":"not_found","detail":"no such route"}`)
++ }
++ }))
++ t.Cleanup(srv.Close)
++ return ms, srv
++}
++
++func (ms *syncStore) profile(scope, text string) {
++ ms.mu.Lock()
++ defer ms.mu.Unlock()
++ ms.profiles[scope] = text
++}
++
++func (ms *syncStore) calls() []string {
++ ms.mu.Lock()
++ defer ms.mu.Unlock()
++ return append([]string(nil), ms.requests...)
++}
++
++// syncTree is a home and a project with hand-written text already there.
++type syncTree struct {
++ home string
++ project string
++ state string
++ hand map[string]string
++}
++
++func newSyncTree(t *testing.T) syncTree {
++ t.Helper()
++ home, project := t.TempDir(), t.TempDir()
++ state := filepath.Join(t.TempDir(), "memory-sync.json")
++ tree := syncTree{home: home, project: project, state: state, hand: map[string]string{}}
++ for _, rel := range []string{".codex/AGENTS.md", ".claude/CLAUDE.md", ".gemini/GEMINI.md"} {
++ tree.write(t, home, rel, "my hand-written text\n")
++ }
++ for _, rel := range []string{"AGENTS.md", "CLAUDE.md", "GEMINI.md"} {
++ tree.write(t, project, rel, "my hand-written text\n")
++ }
++ return tree
++}
++
++func (s syncTree) write(t *testing.T, root, rel, text string) {
++ t.Helper()
++ path := filepath.Join(root, filepath.FromSlash(rel))
++ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
++ t.Fatal(err)
++ }
++ if _, ok := s.hand[path]; !ok {
++ s.hand[path] = text
++ }
++}
++
++func (s syncTree) read(t *testing.T, root, rel string) string {
++ t.Helper()
++ raw, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
++ if err != nil {
++ t.Fatal(err)
++ }
++ return string(raw)
++}
++
++// runSync runs the memory command and gives back the exit code and the output.
++func runSync(t *testing.T, args []string, stdin string) (int, string, string) {
++ t.Helper()
++ var stdout, stderr bytes.Buffer
++ code := cmdMemory(append([]string{"sync"}, args...), strings.NewReader(stdin), &stdout, &stderr)
++ return code, stdout.String(), stderr.String()
++}
++
++func TestMemorySyncIsOffByDefault(t *testing.T) {
++ tree := newSyncTree(t)
++ ms, srv := newSyncStore(t)
++ code, out, errOut := runSync(t, []string{"--user-home", tree.home, "--project", tree.project, "--state", tree.state, "--endpoint", srv.URL, "--yes"}, "")
++ if code != 0 {
++ t.Fatalf("sync failed with %d: %s", code, errOut)
++ }
++ if !strings.Contains(out, "No tool is on") {
++ t.Fatalf("the command did not say nothing is on:\n%s", out)
++ }
++ if len(ms.calls()) != 0 {
++ t.Fatalf("the store was called %v with no tool on", ms.calls())
++ }
++ if got := tree.read(t, tree.home, ".codex/AGENTS.md"); got != tree.hand[filepath.Join(tree.home, ".codex/AGENTS.md")] {
++ t.Fatalf("a file changed with no tool on: %q", got)
++ }
++ if _, err := os.Stat(tree.state); !os.IsNotExist(err) {
++ t.Fatalf("the state was written with no tool on: %v", err)
++ }
++}
++
++func TestMemorySyncDryRunWritesNothing(t *testing.T) {
++ tree := newSyncTree(t)
++ _, srv := newSyncStore(t)
++ code, out, errOut := runSync(t, []string{"--tools", "agents", "--user-home", tree.home, "--project", tree.project,
++ "--state", tree.state, "--endpoint", srv.URL, "--dry-run", "--yes"}, "")
++ if code != 0 {
++ t.Fatalf("dry run failed with %d: %s", code, errOut)
++ }
++ if !strings.Contains(out, "+++ ") || !strings.Contains(out, "About you") {
++ t.Fatalf("the dry run did not show the diff:\n%s", out)
++ }
++ if got := tree.read(t, tree.home, ".codex/AGENTS.md"); !strings.HasSuffix(got, "my hand-written text\n") {
++ t.Fatalf("a dry run wrote a file: %q", got)
++ }
++ if _, err := os.Stat(tree.state); !os.IsNotExist(err) {
++ t.Fatalf("a dry run wrote the state: %v", err)
++ }
++}
++
++func TestMemorySyncShowsTheDiffAndWaitsForTheFirstWrite(t *testing.T) {
++ tree := newSyncTree(t)
++ _, srv := newSyncStore(t)
++ args := []string{"--tools", "agents", "--user-home", tree.home, "--project", tree.project, "--state", tree.state, "--endpoint", srv.URL}
++ code, out, errOut := runSync(t, args, "n\nn\n")
++ if code != 0 {
++ t.Fatalf("sync failed with %d: %s", code, errOut)
++ }
++ if !strings.Contains(out, "Write this file for the first time?") {
++ t.Fatalf("the first write did not ask:\n%s", out)
++ }
++ if got := tree.read(t, tree.home, ".codex/AGENTS.md"); !strings.HasSuffix(got, "my hand-written text\n") {
++ t.Fatalf("a refused write changed the file: %q", got)
++ }
++
++ code, out, errOut = runSync(t, args, "y\ny\n")
++ if code != 0 {
++ t.Fatalf("sync failed with %d: %s", code, errOut)
++ }
++ homeText := tree.read(t, tree.home, ".codex/AGENTS.md")
++ if !strings.HasPrefix(homeText, "my hand-written text\n") {
++ t.Fatalf("the hand-written text changed:\n%s", homeText)
++ }
++ for _, want := range []string{"<!-- openvibe-memory:start -->", "I prefer short answers.", "<!-- openvibe-memory:end -->"} {
++ if !strings.Contains(homeText, want) {
++ t.Fatalf("the home file does not hold %q:\n%s", want, homeText)
++ }
++ }
++ projectText := tree.read(t, tree.project, "AGENTS.md")
++ if !strings.HasPrefix(projectText, "my hand-written text\n") || !strings.Contains(projectText, "This project is a demo project.") {
++ t.Fatalf("the project file looks wrong:\n%s", projectText)
++ }
++ state, err := os.ReadFile(tree.state)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !strings.Contains(string(state), `"confirmed": true`) {
++ t.Fatalf("the first write was not confirmed in the state:\n%s", state)
++ }
++}
++
++func TestMemorySyncDoesNotAskAgainOnceAFileIsConfirmed(t *testing.T) {
++ tree := newSyncTree(t)
++ ms, srv := newSyncStore(t)
++ args := []string{"--tools", "agents", "--user-home", tree.home, "--project", tree.project, "--state", tree.state, "--endpoint", srv.URL}
++ if code, _, errOut := runSync(t, args, "y\ny\n"); code != 0 {
++ t.Fatalf("the first sync failed: %s", errOut)
++ }
++ ms.profile("user", "I prefer long answers now.")
++ ms.mu.Lock()
++ ms.items["project:"+memoryupload.Slug(tree.project)] = []memorystore.ListItem{{
++ Scope: "project:" + memoryupload.Slug(tree.project), Kind: "project_rule", Status: "active", Text: "run the narrow test first",
++ }}
++ ms.mu.Unlock()
++ code, out, errOut := runSync(t, args, "")
++ if code != 0 {
++ t.Fatalf("the second sync failed: %s", errOut)
++ }
++ if strings.Contains(out, "first time") {
++ t.Fatalf("a confirmed file asked again:\n%s", out)
++ }
++ text := tree.read(t, tree.home, ".codex/AGENTS.md")
++ if !strings.Contains(text, "I prefer long answers now.") || strings.Contains(text, "I prefer short answers.") {
++ t.Fatalf("the block was not updated:\n%s", text)
++ }
++ projectText := tree.read(t, tree.project, "AGENTS.md")
++ if !strings.Contains(projectText, "- run the narrow test first") {
++ t.Fatalf("the project rule is not in the project file:\n%s", projectText)
++ }
++}
++
++func TestMemorySyncUndoRestoresEveryFile(t *testing.T) {
++ tree := newSyncTree(t)
++ _, srv := newSyncStore(t)
++ args := []string{"--tools", "agents,claude-code,gemini,cursor", "--user-home", tree.home, "--project", tree.project,
++ "--state", tree.state, "--endpoint", srv.URL, "--yes"}
++ if code, _, errOut := runSync(t, args, ""); code != 0 {
++ t.Fatalf("sync failed: %s", errOut)
++ }
++ cursor := filepath.Join(tree.project, ".cursor", "rules", "openvibe-memory.mdc")
++ if _, err := os.Stat(cursor); err != nil {
++ t.Fatalf("the Cursor rule was not created: %v", err)
++ }
++ // The project must not be named again: undo reads the paths from the state.
++ code, out, errOut := runSync(t, []string{"--undo", "--user-home", tree.home, "--state", tree.state}, "")
++ if code != 0 {
++ t.Fatalf("undo failed with %d: %s\n%s", code, errOut, out)
++ }
++ for path, want := range tree.hand {
++ raw, err := os.ReadFile(path)
++ if err != nil {
++ t.Fatalf("%s is gone after undo: %v", path, err)
++ }
++ if string(raw) != want {
++ t.Fatalf("undo did not restore %s:\nwant %q\ngot %q", path, want, string(raw))
++ }
++ }
++ if _, err := os.Stat(cursor); !os.IsNotExist(err) {
++ t.Fatalf("undo left the created Cursor rule behind: %v", err)
++ }
++ st, err := os.ReadFile(tree.state)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if strings.Contains(string(st), `"enabled": true`) {
++ t.Fatalf("a tool is still on after undo:\n%s", st)
++ }
++}
++
++func TestMemorySyncDisableRemovesOnlyThatToolsBlock(t *testing.T) {
++ tree := newSyncTree(t)
++ _, srv := newSyncStore(t)
++ base := []string{"--user-home", tree.home, "--project", tree.project, "--state", tree.state, "--endpoint", srv.URL}
++ if code, _, errOut := runSync(t, append(base, "--tools", "agents,gemini", "--yes"), ""); code != 0 {
++ t.Fatalf("sync failed: %s", errOut)
++ }
++ if code, _, errOut := runSync(t, append(base, "--disable", "agents"), ""); code != 0 {
++ t.Fatalf("disable failed: %s", errOut)
++ }
++ if got := tree.read(t, tree.home, ".codex/AGENTS.md"); got != tree.hand[filepath.Join(tree.home, ".codex/AGENTS.md")] {
++ t.Fatalf("disable did not restore the AGENTS.md file: %q", got)
++ }
++ if got := tree.read(t, tree.home, ".gemini/GEMINI.md"); !strings.Contains(got, "<!-- openvibe-memory:start -->") {
++ t.Fatalf("disable touched another tool's file:\n%s", got)
++ }
++}
++
++func TestMemorySyncWritesAThinClaudeImport(t *testing.T) {
++ tree := newSyncTree(t)
++ _, srv := newSyncStore(t)
++ args := []string{"--tools", "claude-code", "--user-home", tree.home, "--project", tree.project, "--state", tree.state, "--endpoint", srv.URL, "--yes"}
++ if code, _, errOut := runSync(t, args, ""); code != 0 {
++ t.Fatalf("sync failed: %s", errOut)
++ }
++ projectText := tree.read(t, tree.project, "CLAUDE.md")
++ if !strings.Contains(projectText, "@AGENTS.md") || strings.Contains(projectText, "I prefer short answers.") {
++ t.Fatalf("the project CLAUDE.md is not a thin import:\n%s", projectText)
++ }
++ homeText := tree.read(t, tree.home, ".claude/CLAUDE.md")
++ want := "@" + filepath.Join(tree.home, ".codex", "AGENTS.md")
++ if !strings.Contains(homeText, want) {
++ t.Fatalf("the home CLAUDE.md does not import %q:\n%s", want, homeText)
++ }
++}
++
++func TestMemorySyncNeedsAProjectForCursor(t *testing.T) {
++ tree := newSyncTree(t)
++ _, srv := newSyncStore(t)
++ code, _, errOut := runSync(t, []string{"--tools", "cursor", "--user-home", tree.home, "--state", tree.state, "--endpoint", srv.URL, "--yes"}, "")
++ if code == 0 || !strings.Contains(errOut, "at least one --project") {
++ t.Fatalf("cursor without a project was accepted: code=%d err=%s", code, errOut)
++ }
++}
++
++func TestMemorySyncStoreFailureWritesNothing(t *testing.T) {
++ tree := newSyncTree(t)
++ ms, srv := newSyncStore(t)
++ ms.profileFailure = 503
++ code, _, errOut := runSync(t, []string{"--tools", "agents", "--user-home", tree.home, "--project", tree.project,
++ "--state", tree.state, "--endpoint", srv.URL, "--yes"}, "")
++ if code == 0 {
++ t.Fatal("a failed store read was not an error")
++ }
++ if !strings.Contains(errOut, "memory.unavailable") {
++ t.Fatalf("the error does not say what failed: %s", errOut)
++ }
++ if got := tree.read(t, tree.home, ".codex/AGENTS.md"); !strings.HasSuffix(got, "my hand-written text\n") {
++ t.Fatalf("a failed read still wrote a file: %q", got)
++ }
++}
ddiff --git a/docs/memory.md b/docs/memory.md
iindex f9072be..4fca2b2 100644
---- a/docs/memory.md
++++ b/docs/memory.md
@@@ -14,8 +14,9 @@ after it has shown you what would leave the machine and after you have said
yes. The store is yours: it is sealed, only your own token opens it, and
nothing but the items themselves is sent.
--This page is stage M1 of OpenVibe Memory (the readers) and stage M2a (the
--upload). The page that shows your memory comes later.
++This page is stage M1 of OpenVibe Memory (the readers), stage M2a (the
++upload) and stage M4 (the sync back into the tools). The page that shows your
++memory comes later.
## The commands
@@@ -23,6 +24,7 @@ upload). The page that shows your memory comes later.
openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
openvibe-node memory upload [--tools a,b] [--project DIR …] [--dry-run] [--yes]
openvibe-node memory status [--show]
++openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]
```
| Flag | What it does |
@@@ -36,6 +38,10 @@ openvibe-node memory status [--show]
| `--endpoint URL` | the memory store (default `https://ai.openvibe.services`) |
| `--token-env NAME` | the variable the access token is read from (default `OPENVIBE_TOKEN`) |
| `--user-home DIR` | read this directory as the person's home (default: yours) |
++| `--tools a,b` | sync: turn these tools on |
++| `--disable a,b` | sync: turn these tools off and remove their blocks |
++| `--undo` | sync: remove every block this command wrote and turn the tools off |
++| `--state FILE` | sync: the opt-in file (default: the Node's state directory) |
Without `--project` only the user-level files are read: the files that hold
your own preferences across every project. With `--project` the project files
@@@ -346,3 +352,82 @@ upload:
- the request that leaves holds no absolute path, no hostname and no name of
the person;
- the token is never in an error message.
++
++## The sync: back into the tools
++
++```sh
++openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]
++openvibe-node memory sync --disable a,b
++openvibe-node memory sync --undo
++```
++
++The sync is the other direction: it writes **your own** profile and the
++project's rules from your memory store into the places the coding tools
++already read. It is **off for every tool** until you turn that tool on:
++
++| Tool | On with | Files it writes |
++|---|---|---|
++| AGENTS.md | `--tools agents` | `~/.codex/AGENTS.md`, the project's `AGENTS.md` |
++| Claude Code | `--tools claude-code` | `~/.claude/CLAUDE.md`, the project's `CLAUDE.md`: a thin `@` import of the AGENTS.md block, not a copy |
++| Gemini CLI | `--tools gemini` | `~/.gemini/GEMINI.md`, the project's `GEMINI.md` |
++| Cursor | `--tools cursor` | the project's `.cursor/rules/openvibe-memory.mdc` |
++| GitHub Copilot | `--tools copilot` | the project's `.github/copilot-instructions.md` |
++
++`--disable a,b` turns those tools off and takes their blocks out again.
++`--undo` does that for every tool that is on, also when `--project` is not
++named again: the state file remembers every file it wrote.
++
++### One marked block, never your text
++
++Every write is inside one block, and the text outside it is not touched:
++
++```markdown
++<!-- openvibe-memory:start -->
++<!-- Managed by OpenVibe Node from your OpenVibe Memory. … -->
++## About you
++
++…the cached profile…
++
++## Project rules
++
++- …an active project_rule item…
++<!-- openvibe-memory:end -->
++```
++
++- A file that already has the block has only that block replaced.
++- A file with two blocks, or half a block, is refused and left alone.
++- The memory text is scrubbed of the markers before it is written, so an item
++ cannot open or close a block.
++- `--undo` removes the block and the separator the Node added before it; a file
++ that the Node created (the Cursor rule) is removed again. The state file
++ records the separator so the hand-written part is byte-identical afterwards.
++
++### The diff gate
++
++The first time the Node writes a file it shows a diff and asks. Anything but
++`y` or `yes` leaves the file as it was. `--yes` answers for you, for scripted
++setups; `--dry-run` shows every diff and writes nothing. A later sync of a file
++that was already confirmed shows the diff and writes it without asking again.
++
++Project files may be shared or committed: the command says so before it writes
++one, and the block says so too. Turn the tool off with `--disable` if that is
++not what you want.
++
++### Where the content comes from
++
++The sync uses the same person token as the upload (`OPENVIBE_TOKEN`, or
++`--token-env`), never a copy held by the Node. It reads:
++
++- `GET /api/v1/memory/profile?scope=user`: the cached profile of you;
++- `GET /api/v1/memory/profile?scope=project:<slug>` and
++ `GET /api/v1/memory/items?scope=project:<slug>`: the project's profile and its
++ active `project_rule` items.
++
++The project slug is the same slug the upload uses: the folder's name,
++lowercased and slugged, never an absolute path. Nothing is written when the
++store cannot be read; the command fails before it touches a file.
++
++The opt-in state lives in the Node's state directory
++(`memory-sync.json`, mode 0600). It holds the tools that are on, the paths that
++were written and whether the first diff was confirmed: paths and choices, never
++the memory text.
ddiff --git a/internal/memorystore/store.go b/internal/memorystore/store.go
iindex 53788bf..eefcb3c 100644
---- a/internal/memorystore/store.go
++++ b/internal/memorystore/store.go
@@@ -298,6 +298,39 @@ func (c *Client) postBatch(ctx context.Context, chunk []Item) (BatchResult, erro
return out, nil
}
++// Profile is the person's cached profile of one scope: the small summary the
++// store builds from their active notes.
++type Profile struct {
++ // Scope is `user` or `project:<slug>`.
++ Scope string `json:"scope"`
++ // Text is the profile itself.
++ Text string `json:"text"`
++ // BuiltAt is when the store built it, RFC 3339.
++ BuiltAt string `json:"built_at"`
++}
++
++// Profile gives the person's own profile of one scope.
++func (c *Client) Profile(ctx context.Context, scope string) (Profile, error) {
++ path := "/api/v1/memory/profile"
++ if scope != "" {
++ path += "?scope=" + url.QueryEscape(scope)
++ }
++ raw, status, err := c.do(ctx, http.MethodGet, path, nil)
++ if err != nil {
++ return Profile{}, err
++ }
++ if status/100 != 2 {
++ return Profile{}, c.apiError(status, raw)
++ }
++ var res struct {
++ Profile Profile `json:"profile"`
++ }
++ if err := json.Unmarshal(raw, &res); err != nil {
++ return Profile{}, errors.New("the memory store answered something that is not JSON")
++ }
++ return res.Profile, nil
++}
++
// List gives the person's own items.
func (c *Client) List(ctx context.Context, scope, query string) (ListResult, error) {
path := "/api/v1/memory/items"
ddiff --git a/internal/memorysync/state.go b/internal/memorysync/state.go
nnew file mode 100644
iindex 0000000..42d3ff2
---- /dev/null
++++ b/internal/memorysync/state.go
@@@ -0,0 +1,140 @@
++package memorysync
++
++import (
++ "encoding/json"
++ "errors"
++ "os"
++ "path/filepath"
++)
++
++// State is what the person has turned on, kept on this machine only. It holds
++// paths and choices, never memory text.
++type State struct {
++ // Version is 1.
++ Version int `json:"version"`
++ // Tools is the per-tool opt-in and what was written for it.
++ Tools map[string]*ToolState `json:"tools,omitempty"`
++}
++
++// ToolState is one tool's opt-in state.
++type ToolState struct {
++ // Enabled is the person's choice for this tool. Off unless they said so.
++ Enabled bool `json:"enabled,omitempty"`
++ // Files are the files the tool's block was written into.
++ Files map[string]*FileState `json:"files,omitempty"`
++}
++
++// FileState is what the Node needs to undo one file.
++type FileState struct {
++ // Confirmed is true once the person has seen the diff and said yes.
++ Confirmed bool `json:"confirmed,omitempty"`
++ // Created is true when the Node created the file.
++ Created bool `json:"created,omitempty"`
++ // Sep is the separator the Node added before a block in a file that
++ // already had text (or "" when it added none). Undo removes exactly it.
++ Sep string `json:"sep,omitempty"`
++}
++
++// NewState is an empty state.
++func NewState() *State { return &State{Version: 1, Tools: map[string]*ToolState{}} }
++
++// LoadState reads the state file. A missing file is an empty state, never an
++// error: the first run has nothing turned on.
++func LoadState(path string) (*State, error) {
++ raw, err := os.ReadFile(path)
++ if errors.Is(err, os.ErrNotExist) {
++ return NewState(), nil
++ }
++ if err != nil {
++ return nil, err
++ }
++ st := NewState()
++ if err := json.Unmarshal(raw, st); err != nil {
++ return nil, err
++ }
++ if st.Tools == nil {
++ st.Tools = map[string]*ToolState{}
++ }
++ if st.Version == 0 {
++ st.Version = 1
++ }
++ return st, nil
++}
++
++// Save writes the state with owner-only permissions, through a temporary file
++// in the same directory so a crash never leaves half a state.
++func (s *State) Save(path string) error {
++ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
++ return err
++ }
++ raw, err := json.MarshalIndent(s, "", " ")
++ if err != nil {
++ return err
++ }
++ raw = append(raw, '\n')
++ tmp, err := os.CreateTemp(filepath.Dir(path), ".memory-sync-")
++ if err != nil {
++ return err
++ }
++ name := tmp.Name()
++ defer os.Remove(name)
++ if err := tmp.Chmod(0o600); err != nil {
++ tmp.Close()
++ return err
++ }
++ if _, err := tmp.Write(raw); err != nil {
++ tmp.Close()
++ return err
++ }
++ if err := tmp.Close(); err != nil {
++ return err
++ }
++ return os.Rename(name, path)
++}
++
++// Tool gives one tool's state, creating it when it is new.
++func (s *State) Tool(id string) *ToolState {
++ if s.Tools == nil {
++ s.Tools = map[string]*ToolState{}
++ }
++ t := s.Tools[id]
++ if t == nil {
++ t = &ToolState{Files: map[string]*FileState{}}
++ s.Tools[id] = t
++ }
++ if t.Files == nil {
++ t.Files = map[string]*FileState{}
++ }
++ return t
++}
++
++// File gives one file's state, creating it when it is new.
++func (s *State) File(tool, path string) *FileState {
++ t := s.Tool(tool)
++ f := t.Files[path]
++ if f == nil {
++ f = &FileState{}
++ t.Files[path] = f
++ }
++ return f
++}
++
++// FileStateOf gives one file's state without creating it.
++func (s *State) FileStateOf(tool, path string) *FileState {
++ t := s.Tools[tool]
++ if t == nil {
++ return nil
++ }
++ return t.Files[path]
++}
++
++// EnabledTools lists the tools the person turned on, in the fixed tool order.
++func (s *State) EnabledTools() []string {
++ out := []string{}
++ for _, id := range ToolIDs() {
++ if t := s.Tools[id]; t != nil && t.Enabled {
++ out = append(out, id)
++ }
++ }
++ return out
++}
ddiff --git a/internal/memorysync/sync.go b/internal/memorysync/sync.go
nnew file mode 100644
iindex 0000000..7064193
---- /dev/null
++++ b/internal/memorysync/sync.go
@@@ -0,0 +1,366 @@
++// Package memorysync writes the person's OpenVibe Memory profile and project
++// rules into the files the coding tools already read.
++//
++// It writes only inside one clearly marked block, and it never touches the
++// person's hand-written text outside that block:
++//
++// <!-- openvibe-memory:start -->
++// ...
++// <!-- openvibe-memory:end -->
++//
++// Every file is opt-in per tool, and the first write to a file shows a diff and
++// asks. Removing a block restores the file it was written into; a file this
++// package created is removed again when it held only its own block.
++package memorysync
++
++import (
++ "bytes"
++ "fmt"
++ "path/filepath"
++ "strings"
++)
++
++// StartMarker and EndMarker delimit the block. The person's own text outside
++// them is never changed, and the memory text is scrubbed of both markers before
++// it is written, so a memory item cannot open or close a block.
++const (
++ StartMarker = "<!-- openvibe-memory:start -->"
++ EndMarker = "<!-- openvibe-memory:end -->"
++)
++
++// The block stays small enough for a tool's context: at most this many project
++// rules, each this long, and this many characters in all. The profile itself is
++// already capped by the store; the note at the cut says where the rest is.
++const (
++ MaxRules = 40
++ MaxRuleRunes = 500
++ MaxBlockRunes = 12000
++)
++
++// Root says where a target file lives.
++type Root int
++
++const (
++ // RootHome is the person's home directory.
++ RootHome Root = iota
++ // RootProject is a project directory the person gave.
++ RootProject
++)
++
++// Target is one file of one tool that receives a block.
++type Target struct {
++ // Tool is the stable id of the tool: agents, claude-code, gemini, cursor,
++ // copilot.
++ Tool string
++ // Name is how the tool is called.
++ Name string
++ // Root is where the file lives.
++ Root Root
++ // Rel is the file under that root, with forward slashes.
++ Rel string
++ // Import, when set, makes the block a thin import of the file the tool's
++ // main block is written into, instead of a copy of the memory. It is given
++ // the person's home directory so a home-level import can be absolute.
++ Import func(home string) string
++ // Frontmatter is written above the block when this package creates the
++ // file. It is never added to a file that already exists.
++ Frontmatter string
++ // Create says the file may not exist yet and this package may create it.
++ Create bool
++}
++
++// Targets returns the files the sync writes, in a fixed order.
++func Targets() []Target {
++ return []Target{
++ {Tool: "agents", Name: "AGENTS.md (Codex and friends)", Root: RootHome, Rel: ".codex/AGENTS.md", Create: true},
++ {Tool: "agents", Name: "AGENTS.md (Codex and friends)", Root: RootProject, Rel: "AGENTS.md", Create: true},
++ {Tool: "claude-code", Name: "Claude Code", Root: RootHome, Rel: ".claude/CLAUDE.md", Create: true,
++ Import: func(home string) string { return "@" + filepath.Join(home, ".codex", "AGENTS.md") }},
++ {Tool: "claude-code", Name: "Claude Code", Root: RootProject, Rel: "CLAUDE.md", Create: true,
++ Import: func(string) string { return "@AGENTS.md" }},
++ {Tool: "gemini", Name: "Gemini CLI", Root: RootHome, Rel: ".gemini/GEMINI.md", Create: true},
++ {Tool: "gemini", Name: "Gemini CLI", Root: RootProject, Rel: "GEMINI.md", Create: true},
++ {Tool: "cursor", Name: "Cursor", Root: RootProject, Rel: ".cursor/rules/openvibe-memory.mdc", Create: true,
++ Frontmatter: "---\ndescription: OpenVibe Memory: your profile and this project's rules\nalwaysApply: true\n---\n"},
++ {Tool: "copilot", Name: "GitHub Copilot", Root: RootProject, Rel: ".github/copilot-instructions.md", Create: true},
++ }
++}
++
++// ToolIDs are the tools a person may turn on.
++func ToolIDs() []string { return []string{"agents", "claude-code", "gemini", "cursor", "copilot"} }
++
++// KnownTool says whether id names a tool this package writes.
++func KnownTool(id string) bool {
++ for _, tool := range ToolIDs() {
++ if tool == id {
++ return true
++ }
++ }
++ return false
++}
++
++// Content is what a block says: the profile of one scope and the project rules
++// of that scope.
++type Content struct {
++ // Scope is `user` or `project:<slug>`.
++ Scope string
++ // Profile is the cached profile of that scope, already built by the store.
++ Profile string
++ // Rules are the active project_rule items of that scope.
++ Rules []string
++}
++
++// Body renders the text between the markers, or "" when there is nothing to
++// write.
++func Body(content Content) string {
++ var b strings.Builder
++ text := clean(content.Profile)
++ rules := cleanRules(content.Rules)
++ if text == "" && len(rules) == 0 {
++ return ""
++ }
++ b.WriteString("<!-- Managed by OpenVibe Node from your OpenVibe Memory. Edit the memory in OpenVibe, not in this block. This file may be shared or committed. -->\n")
++ if text != "" {
++ if isProject(content.Scope) {
++ b.WriteString("\n## About this project\n\n")
++ } else {
++ b.WriteString("\n## About you\n\n")
++ }
++ b.WriteString(text)
++ b.WriteString("\n")
++ }
++ if len(rules) > 0 {
++ b.WriteString("\n## Project rules\n\n")
++ for _, rule := range rules {
++ b.WriteString("- " + rule + "\n")
++ }
++ }
++ body := strings.TrimRight(b.String(), "\n")
++ if len([]rune(body)) > MaxBlockRunes {
++ cut := string([]rune(body)[:MaxBlockRunes])
++ if i := strings.LastIndex(cut, "\n"); i > 0 {
++ cut = cut[:i]
++ }
++ body = cut + "\n\n_More notes are in your OpenVibe Memory; this block is shortened to fit._"
++ }
++ return strings.TrimRight(body, "\n")
++}
++
++// Block puts the markers around a body. An empty body means the block is not
++// written (and an old one is removed).
++func Block(body string) string {
++ body = strings.TrimRight(Sanitize(body), "\n")
++ if body == "" {
++ return ""
++ }
++ return StartMarker + "\n" + body + "\n" + EndMarker + "\n"
++}
++
++// ImportBody is the thin block a tool that reads AGENTS.md gets.
++func ImportBody(importLine string) string {
++ importLine = strings.TrimSpace(importLine)
++ if importLine == "" {
++ return ""
++ }
++ return "<!-- Your OpenVibe Memory is kept in the AGENTS.md file this imports. Edit it in OpenVibe, not here. -->\n\n" + importLine
++}
++
++// Sanitize keeps the memory text from opening or closing a block.
++func Sanitize(text string) string {
++ text = strings.ReplaceAll(text, StartMarker, "openvibe-memory start")
++ text = strings.ReplaceAll(text, EndMarker, "openvibe-memory end")
++ return strings.ReplaceAll(text, "openvibe-memory:", "openvibe memory:")
++}
++
++func clean(text string) string {
++ return strings.TrimSpace(Sanitize(text))
++}
++
++func cleanRules(rules []string) []string {
++ out := make([]string, 0, len(rules))
++ seen := map[string]bool{}
++ for _, rule := range rules {
++ rule = strings.Join(strings.Fields(Sanitize(rule)), " ")
++ if len([]rune(rule)) > MaxRuleRunes {
++ rule = string([]rune(rule)[:MaxRuleRunes]) + "…"
++ }
++ if rule == "" || seen[rule] {
++ continue
++ }
++ seen[rule] = true
++ out = append(out, rule)
++ if len(out) == MaxRules {
++ break
++ }
++ }
++ return out
++}
++
++func isProject(scope string) bool { return strings.HasPrefix(scope, "project:") }
++
++// find gives the block's bytes and where they are. It refuses a file with two
++// blocks or half a block: rewriting one of them could hide the person's text.
++func find(existing []byte) (start, end int, found bool, err error) {
++ start = bytes.Index(existing, []byte(StartMarker))
++ end = bytes.Index(existing, []byte(EndMarker))
++ switch {
++ case start < 0 && end < 0:
++ return 0, 0, false, nil
++ case start < 0 || end < 0:
++ return 0, 0, false, fmt.Errorf("the file holds half an OpenVibe Memory block (a marker without its pair); leaving it alone")
++ case end < start:
++ return 0, 0, false, fmt.Errorf("the OpenVibe Memory end marker comes before its start; leaving the file alone")
++ }
++ // The block ends after the end marker and its line ending.
++ after := end + len(EndMarker)
++ if after < len(existing) && existing[after] == '\r' {
++ after++
++ }
++ if after < len(existing) && existing[after] == '\n' {
++ after++
++ }
++ if bytes.Contains(existing[after:], []byte(StartMarker)) || bytes.Contains(existing[after:], []byte(EndMarker)) {
++ return 0, 0, false, fmt.Errorf("the file holds more than one OpenVibe Memory block; leaving it alone")
++ }
++ return start, after, true, nil
++}
++
++// Replace writes a block into a file: it replaces the one already there,
++// appends a new one, or leaves an empty block out. It gives back the file, the
++// separator it added before the block (for an exact undo), and whether the
++// bytes changed.
++func Replace(existing []byte, block string) (out []byte, sep string, changed bool, err error) {
++ start, end, found, err := find(existing)
++ if err != nil {
++ return nil, "", false, err
++ }
++ if block == "" {
++ return existing, "", false, fmt.Errorf("an empty block is not written; call Remove to take one out")
++ }
++ if found {
++ out = append(append(append([]byte{}, existing[:start]...), []byte(block)...), existing[end:]...)
++ return out, "", !bytes.Equal(out, existing), nil
++ }
++ var b bytes.Buffer
++ b.Write(existing)
++ switch {
++ case len(existing) == 0:
++ case bytes.HasSuffix(existing, []byte("\n\n")):
++ case bytes.HasSuffix(existing, []byte("\n")):
++ sep = "\n"
++ default:
++ sep = "\n\n"
++ }
++ b.WriteString(sep)
++ b.WriteString(block)
++ return b.Bytes(), sep, true, nil
++}
++
++// Remove takes the block out of a file and gives back the file, whether a block
++// was there, and the old separator it also removed. The rest of the file is
++// byte-identical.
++func Remove(existing []byte, sep string) (out []byte, found bool, err error) {
++ start, end, found, err := find(existing)
++ if err != nil || !found {
++ return existing, false, err
++ }
++ out, _ = removeAt(existing, start, end)
++ if sep != "" && bytes.HasSuffix(out[:start], []byte(sep)) {
++ out = append(append([]byte{}, out[:start-len(sep)]...), out[start:]...)
++ }
++ return out, true, nil
++}
++
++func removeAt(existing []byte, start, end int) ([]byte, string) {
++ out := append(append([]byte{}, existing[:start]...), existing[end:]...)
++ return out, ""
++}
++
++// OnlyOurContent says whether a file holds nothing but the frontmatter this
++// package writes and whitespace. Such a file is removed on undo.
++func OnlyOurContent(data []byte, frontmatter string) bool {
++ text := strings.TrimSpace(string(data))
++ if frontmatter != "" {
++ text = strings.TrimSpace(strings.TrimPrefix(text, strings.TrimSpace(frontmatter)))
++ }
++ return text == ""
++}
++
++// TargetFor finds the target a file belongs to, by the end of its path. It is
++// used on undo, where the state has the absolute path but the project may not
++// be named again on the command line.
++func TargetFor(path string) (Target, bool) {
++ slash := filepath.ToSlash(path)
++ for _, target := range Targets() {
++ if slash == target.Rel || strings.HasSuffix(slash, "/"+target.Rel) {
++ return target, true
++ }
++ }
++ return Target{}, false
++}
++
++// Diff is a small line diff for the person to read: three lines of context
++// around the part that changed. It is not a patch to apply; it is the gate
++// before the first write.
++func Diff(path string, before, after []byte) string {
++ oldLines := lines(before)
++ newLines := lines(after)
++ prefix := 0
++ for prefix < len(oldLines) && prefix < len(newLines) && oldLines[prefix] == newLines[prefix] {
++ prefix++
++ }
++ suffix := 0
++ for suffix < len(oldLines)-prefix && suffix < len(newLines)-prefix &&
++ oldLines[len(oldLines)-1-suffix] == newLines[len(newLines)-1-suffix] {
++ suffix++
++ }
++ context := 3
++ from := prefix - context
++ if from < 0 {
++ from = 0
++ }
++ to := prefix
++ oldEnd := len(oldLines) - suffix
++ newEnd := len(newLines) - suffix
++ oldTail := oldEnd + context
++ if oldTail > len(oldLines) {
++ oldTail = len(oldLines)
++ }
++ newTail := newEnd + context
++ if newTail > len(newLines) {
++ newTail = len(newLines)
++ }
++
++ var b strings.Builder
++ fmt.Fprintf(&b, "--- %s\n+++ %s\n", path, path)
++ fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", from+1, oldTail-from, from+1, newTail-from)
++ for _, line := range oldLines[from:to] {
++ b.WriteString(" " + line + "\n")
++ }
++ for _, line := range oldLines[to:oldEnd] {
++ b.WriteString("-" + line + "\n")
++ }
++ for _, line := range newLines[to:newEnd] {
++ b.WriteString("+" + line + "\n")
++ }
++ oldCtx := oldLines[oldEnd:oldTail]
++ newCtx := newLines[newEnd:newTail]
++ n := len(oldCtx)
++ if len(newCtx) < n {
++ n = len(newCtx)
++ }
++ for i := 0; i < n; i++ {
++ b.WriteString(" " + oldCtx[i] + "\n")
++ }
++ return b.String()
++}
++
++// lines splits a file on newlines and keeps no phantom line after the last
++// newline, so a diff is about the text and not about the ending.
++func lines(data []byte) []string {
++ if len(data) == 0 {
++ return nil
++ }
++ text := strings.TrimSuffix(string(data), "\n")
++ return strings.Split(text, "\n")
++}
ddiff --git a/internal/memorysync/sync_test.go b/internal/memorysync/sync_test.go
nnew file mode 100644
iindex 0000000..5db8947
---- /dev/null
++++ b/internal/memorysync/sync_test.go
@@@ -0,0 +1,183 @@
++package memorysync
++
++import (
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "testing"
++)
++
++func blockFor(profile string) string {
++ return Block(Body(Content{Scope: "user", Profile: profile}))
++}
++
++func TestTheBlockRoundTripLeavesHandTextByteIdentical(t *testing.T) {
++ cases := []struct {
++ name string
++ hand string
++ }{
++ {"text ends with a newline", "# My notes\n\nA rule I wrote.\n"},
++ {"text ends with a blank line", "# My notes\n\nA rule I wrote.\n\n"},
++ {"text has no final newline", "# My notes\n\nA rule I wrote."},
++ {"empty file", ""},
++ {"only whitespace", "\n"},
++ }
++ for _, tc := range cases {
++ t.Run(tc.name, func(t *testing.T) {
++ before := []byte(tc.hand)
++ with, sep, changed, err := Replace(before, blockFor("I prefer short answers."))
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !changed {
++ t.Fatal("the block was not written")
++ }
++ if !strings.Contains(string(with), StartMarker) || !strings.Contains(string(with), EndMarker) {
++ t.Fatalf("the markers are missing:\n%s", with)
++ }
++ after, found, err := Remove(with, sep)
++ if err != nil || !found {
++ t.Fatalf("remove: found=%v err=%v", found, err)
++ }
++ if string(after) != tc.hand {
++ t.Fatalf("hand-written text changed:\nwant %q\ngot %q", tc.hand, string(after))
++ }
++ })
++ }
++}
++
++func TestTheBlockIsReplacedInPlaceAndTheTextAroundItIsNotTouched(t *testing.T) {
++ before := []byte("# Mine\n\nhand text before\n")
++ with, _, _, err := Replace(before, Block(Body(Content{Scope: "user", Profile: "first profile"})))
++ if err != nil {
++ t.Fatal(err)
++ }
++ handAfter := "\nhand text after\n"
++ with = append(with, []byte(handAfter)...)
++ newBlock := Block(Body(Content{Scope: "user", Profile: "second profile"}))
++ updated, sep2, changed, err := Replace(with, newBlock)
++ if err != nil || !changed {
++ t.Fatalf("replace: changed=%v err=%v", changed, err)
++ }
++ text := string(updated)
++ if strings.Count(text, StartMarker) != 1 || strings.Count(text, EndMarker) != 1 {
++ t.Fatalf("there is not exactly one block:\n%s", text)
++ }
++ if !strings.Contains(text, "hand text before\n") || !strings.HasSuffix(text, handAfter) {
++ t.Fatalf("the hand-written text around the block changed:\n%s", text)
++ }
++ if strings.Contains(text, "first profile") || !strings.Contains(text, "second profile") {
++ t.Fatalf("the block was not updated:\n%s", text)
++ }
++ // Replace does not add a separator when the block was already there.
++ if sep2 != "" {
++ t.Fatalf("a replacement added a separator %q", sep2)
++ }
++ // Before the old block, the text is unchanged; the block is the only delta.
++ if !strings.HasPrefix(text, "# Mine\n\nhand text before\n") {
++ t.Fatalf("the text before the block changed:\n%s", text)
++ }
++}
++
++func TestAMemoryTextCannotOpenOrCloseTheBlock(t *testing.T) {
++ profile := "a note with " + EndMarker + " in it\nand " + StartMarker + " too"
++ with, _, _, err := Replace([]byte("hand\n"), blockFor(profile))
++ if err != nil {
++ t.Fatal(err)
++ }
++ if strings.Count(string(with), StartMarker) != 1 || strings.Count(string(with), EndMarker) != 1 {
++ t.Fatalf("a memory item broke the block:\n%s", with)
++ }
++ after, found, err := Remove(with, "\n")
++ if err != nil || !found {
++ t.Fatalf("remove: found=%v err=%v", found, err)
++ }
++ if string(after) != "hand\n" {
++ t.Fatalf("the hand text changed: %q", after)
++ }
++}
++
++func TestTwoBlocksOrHalfABlockAreRefused(t *testing.T) {
++ one := blockFor("one")
++ two := string(one) + "\n" + string(one)
++ if _, _, _, err := Replace([]byte(two), blockFor("new")); err == nil {
++ t.Fatal("a file with two blocks was rewritten")
++ }
++ half := "hand\n" + StartMarker + "\nno end"
++ if _, _, _, err := Replace([]byte(half), blockFor("new")); err == nil {
++ t.Fatal("a file with half a block was rewritten")
++ }
++}
++
++func TestAnEmptyBodyMeansNoBlock(t *testing.T) {
++ if got := Block(Body(Content{Scope: "user"})); got != "" {
++ t.Fatalf("an empty memory wrote a block: %q", got)
++ }
++}
++
++func TestTheDiffShowsTheChangedLines(t *testing.T) {
++ before := []byte("# Mine\n\nhand\n")
++ after, _, _, err := Replace(before, blockFor("the profile"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ diff := Diff("AGENTS.md", before, after)
++ for _, want := range []string{"--- AGENTS.md", "+++ AGENTS.md", "+## About you", "+the profile", " # Mine"} {
++ if !strings.Contains(diff, want) {
++ t.Fatalf("the diff does not show %q:\n%s", want, diff)
++ }
++ }
++ if strings.Contains(diff, "-## About you") {
++ t.Fatalf("the diff says an added line was removed:\n%s", diff)
++ }
++}
++
++func TestOnlyOurContentKnowsTheFrontmatter(t *testing.T) {
++ fm := "---\ndescription: OpenVibe Memory\n---\n"
++ if !OnlyOurContent([]byte(fm+"\n\n"), fm) {
++ t.Fatal("a file with only its frontmatter is not seen as ours")
++ }
++ if OnlyOurContent([]byte(fm+"\nhand\n"), fm) {
++ t.Fatal("a file with a person's line is seen as ours")
++ }
++}
++
++func TestTargetForFindsTheCursorFile(t *testing.T) {
++ target, ok := TargetFor("/home/you/project/.cursor/rules/openvibe-memory.mdc")
++ if !ok || target.Tool != "cursor" || target.Frontmatter == "" {
++ t.Fatalf("the Cursor target was not found: %+v %v", target, ok)
++ }
++}
++
++func TestStateRoundTripKeepsTheUndoSeparator(t *testing.T) {
++ dir := t.TempDir()
++ path := filepath.Join(dir, "memory-sync.json")
++ st := NewState()
++ st.Tool("agents").Enabled = true
++ file := st.File("agents", filepath.Join(dir, "AGENTS.md"))
++ file.Confirmed = true
++ file.Created = true
++ file.Sep = "\n\n"
++ if err := st.Save(path); err != nil {
++ t.Fatal(err)
++ }
++ info, err := os.Stat(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
++ t.Fatalf("the state is %v, want 0600", info.Mode().Perm())
++ }
++ loaded, err := LoadState(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ got := loaded.FileStateOf("agents", filepath.Join(dir, "AGENTS.md"))
++ if got == nil || !got.Confirmed || !got.Created || got.Sep != "\n\n" {
++ t.Fatalf("the state did not round trip: %+v", got)
++ }
++ if len(loaded.EnabledTools()) != 1 || loaded.EnabledTools()[0] != "agents" {
++ t.Fatalf("the enabled tools are %v", loaded.EnabledTools())
++ }
++}