openvibers/openvibe.link Commits
memory sync: write the profile and project rules into the tools' own files (#113)
Changes
@@@ -42,6 +42,7 @@ openvibe-node approvals # the computer plugin at level ask: what waits; approveopenvibe-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 removedopenvibe-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@@@ -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)nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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)++ }++}@@@ -14,8 +14,9 @@ after it has shown you what would leave the machine and after you have saidyes. The store is yours: it is sealed, only your own token opens it, andnothing 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 holdyour 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 ofthe 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.@@@ -298,6 +298,39 @@ func (c *Client) postBatch(ctx context.Context, chunk []Item) (BatchResult, erroreturn 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"nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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")++}nnew file mode 100644@@@ -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())++ }++}
@@ -42,6 +42,7 @@ openvibe-node approvals # the computer plugin at level ask: what waits; approve
@@ -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 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 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 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
## What is in here
@@ -15,6 +15,7 @@ import (
@@ -15,6 +15,7 @@ import (
const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--json]\n" +
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 upload [--tools a,b] [--project DIR …] [--dry-run] [--yes]\n" +
"openvibe-node memory status [--show]\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" +
"\n" +
" scan reads the memory files of the coding tools on this machine and\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" +
" 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] [--
@@ -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" +
" 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" +
" what would leave the machine and asks before it sends anything.\n" +
" status shows what your own memory store holds.\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" +
"\n" +
" --tools a,b only these adapters (claude-code, agents, gemini, cursor,\n" +
" --tools a,b only these adapters (claude-code, agents, gemini, cursor,\n" +
" copilot, windsurf, cline, roo, kiro, continue, aider,\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 {
@@ -60,6 +65,8 @@ func cmdMemory(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return cmdMemoryUpload(args[1:], stdin, stdout, stderr)
return cmdMemoryUpload(args[1:], stdin, stdout, stderr)
case "status":
case "status":
return cmdMemoryStatus(args[1:], stdout, stderr)
return cmdMemoryStatus(args[1:], stdout, stderr)
+ case "sync":
+ return cmdMemorySync(args[1:], stdin, stdout, stderr)
case "scan":
case "scan":
default:
default:
fmt.Fprint(stderr, memoryUsage)
fmt.Fprint(stderr, memoryUsage)
new file mode 100644
new file mode 100644
@@ -0,0 +1,531 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,345 @@
@@ -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)
+ }
+}
@@ -14,8 +14,9 @@ after it has shown you what would leave the machine and after you have said
@@ -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
yes. The store is yours: it is sealed, only your own token opens it, and
nothing but the items themselves is sent.
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
## The commands
@@ -23,6 +24,7 @@ upload). The page that shows your memory comes later.
@@ -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 scan [--tools a,b] [--project DIR …] [--json]
openvibe-node memory upload [--tools a,b] [--project DIR …] [--dry-run] [--yes]
openvibe-node memory upload [--tools a,b] [--project DIR …] [--dry-run] [--yes]
openvibe-node memory status [--show]
openvibe-node memory status [--show]
+openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]
```
```
| Flag | What it does |
| Flag | What it does |
@@ -36,6 +38,10 @@ openvibe-node memory status [--show]
@@ -36,6 +38,10 @@ openvibe-node memory status [--show]
| `--endpoint URL` | the memory store (default `https://ai.openvibe.services`) |
| `--endpoint URL` | the memory store (default `https://ai.openvibe.services`) |
| `--token-env NAME` | the variable the access token is read from (default `OPENVIBE_TOKEN`) |
| `--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) |
| `--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
Without `--project` only the user-level files are read: the files that hold
your own preferences across every project. With `--project` the project files
your own preferences across every project. With `--project` the project files
@@ -346,3 +352,82 @@ upload:
@@ -346,3 +352,82 @@ upload:
- the request that leaves holds no absolute path, no hostname and no name of
- the request that leaves holds no absolute path, no hostname and no name of
the person;
the person;
- the token is never in an error message.
- 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.
@@ -298,6 +298,39 @@ func (c *Client) postBatch(ctx context.Context, chunk []Item) (BatchResult, erro
@@ -298,6 +298,39 @@ func (c *Client) postBatch(ctx context.Context, chunk []Item) (BatchResult, erro
return out, nil
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.
// List gives the person's own items.
func (c *Client) List(ctx context.Context, scope, query string) (ListResult, error) {
func (c *Client) List(ctx context.Context, scope, query string) (ListResult, error) {
path := "/api/v1/memory/items"
path := "/api/v1/memory/items"
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,366 @@
@@ -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")
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -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())
+ }
+}