openvibers/openvibe.link Commits
Memory M5: memory export and import, the portable file on the Node (#122)
Changes
@@@ -16,6 +16,8 @@ const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--"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" +++ "openvibe-node memory export [--out FILE] [--scope SCOPE] [--force]\n" +++ "openvibe-node memory import --file FILE [--dry-run] [--yes]\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" +@@@ -28,15 +30,20 @@ const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--" 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" +++ " export writes your one portable memory file (JSON) from the store.\n" +++ " import reads that same file back into your store, merging repeats.\n" +"\n" +" --tools a,b only these adapters (claude-code, agents, gemini, cursor,\n" +" copilot, windsurf, cline, roo, kiro, continue, aider,\n" +" command-code, opencode)\n" +" --project DIR read this project as well; may be given more than once\n" +" --json print the items as JSON (scan)\n" +-- " --dry-run print the summary and send nothing (upload)\n" +-- " --yes do not ask before sending (upload)\n" +++ " --dry-run print the summary and send nothing (upload, import)\n" +++ " --yes do not ask before sending (upload, import)\n" +" --show print the items themselves (status)\n" +++ " --out FILE where to write the portable file (export; - for stdout)\n" +++ " --scope SCOPE only one scope (export)\n" +++ " --file FILE the portable file to import (- for stdin)\n" +" --user-home DIR read this directory as the person's home (default: yours)\n"// stringList is a flag that may be given more than once.@@@ -67,6 +74,10 @@ func cmdMemory(args []string, stdin io.Reader, stdout, stderr io.Writer) int {return cmdMemoryStatus(args[1:], stdout, stderr)case "sync":return cmdMemorySync(args[1:], stdin, stdout, stderr)++ case "export":++ return cmdMemoryExport(args[1:], stdout, stderr)++ case "import":++ return cmdMemoryImport(args[1:], stdin, stdout, stderr)case "scan":default:fmt.Fprint(stderr, memoryUsage)nnew file mode 100644@@@ -0,0 +1,186 @@++package main++++import (++ "bytes"++ "context"++ "encoding/json"++ "flag"++ "fmt"++ "io"++ "os"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/memorystore"++)++++// memoryExportUsage is what "openvibe-node memory export" prints.++const memoryExportUsage = "openvibe-node memory export [--out FILE] [--scope SCOPE] [--force]\n" +++ "\n" +++ " Writes your one portable memory file: a JSON document with every note, its\n" +++ " kind, scope, source and confidence, and no account id or note id. The same\n" +++ " file can be imported into another OpenVibe account.\n" +++ "\n" +++ " --out FILE where to write it (default openvibe-memory-YYYY-MM-DD.json;\n" +++ " - writes to stdout)\n" +++ " --scope SCOPE only one scope: user or project:<slug>\n" +++ " --force replace the file if it already exists\n" +++ " --endpoint URL the store (default " + memorystore.DefaultBaseURL + ")\n" +++ " --token-env NAME the variable the access token is read from (default " + memorystore.DefaultTokenEnv + ")\n"++++// memoryImportUsage is what "openvibe-node memory import" prints.++const memoryImportUsage = "openvibe-node memory import --file FILE [--dry-run] [--yes]\n" +++ "\n" +++ " Reads a portable memory file back into your store. The store validates the\n" +++ " format, removes secrets again and merges a note that says the same thing as\n" +++ " one you already have; it never copies the same text twice.\n" +++ "\n" +++ " --file FILE the file to import (- reads stdin)\n" +++ " --dry-run show how many notes the file holds, and send nothing\n" +++ " --yes do not ask before importing\n" +++ " --endpoint URL the store (default " + memorystore.DefaultBaseURL + ")\n" +++ " --token-env NAME the variable the access token is read from (default " + memorystore.DefaultTokenEnv + ")\n"++++// memoryPortable is the part of the portable format this command reads for its++// summary: the format id, its version, and the notes.++type memoryPortable struct {++ Format string `json:"format"`++ Version int `json:"version"`++ ExportedAt string `json:"exported_at"`++ Items []json.RawMessage `json:"items"`++}++++// cmdMemoryExport writes the one portable memory file.++func cmdMemoryExport(args []string, stdout, stderr io.Writer) int {++ fs := flag.NewFlagSet("memory export", flag.ContinueOnError)++ fs.SetOutput(stderr)++ fs.Usage = func() { fmt.Fprint(stderr, memoryExportUsage) }++ out := fs.String("out", "", "where to write the file")++ scope := fs.String("scope", "", "only one scope")++ force := fs.Bool("force", false, "replace an existing 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")++ 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, memoryExportUsage)++ return 2++ }++ token := strings.TrimSpace(os.Getenv(*tokenEnv))++ if token == "" {++ fmt.Fprintf(stderr, "error: %s is not set: it must hold your OpenVibe access token\n", *tokenEnv)++ return 1++ }++ client, err := memorystore.New(memorystore.Options{BaseURL: *endpoint, Token: token})++ if err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ doc, err := client.Export(context.Background(), *scope)++ if err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ var portable memoryPortable++ if err := json.Unmarshal(doc, &portable); err != nil {++ fmt.Fprintln(stderr, "error: the store answered something that is not a portable file")++ return 1++ }++ target := *out++ if target == "" {++ target = "openvibe-memory-" + time.Now().Format("2006-01-02") + ".json"++ }++ if target == "-" {++ if _, err := stdout.Write(doc); err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ fmt.Fprintf(stderr, "Wrote %d notes to stdout.\n", len(portable.Items))++ return 0++ }++ if !*force {++ if _, err := os.Stat(target); err == nil {++ fmt.Fprintf(stderr, "error: %s already exists; pass --force to replace it\n", target)++ return 1++ }++ }++ // The file is the person's own memory: only they may read it.++ if err := os.WriteFile(target, doc, 0o600); err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ fmt.Fprintf(stdout, "Wrote %d notes to %s.\n", len(portable.Items), target)++ return 0++}++++// cmdMemoryImport reads a portable memory file back into the store.++func cmdMemoryImport(args []string, stdin io.Reader, stdout, stderr io.Writer) int {++ fs := flag.NewFlagSet("memory import", flag.ContinueOnError)++ fs.SetOutput(stderr)++ fs.Usage = func() { fmt.Fprint(stderr, memoryImportUsage) }++ file := fs.String("file", "-", "the file to import")++ dryRun := fs.Bool("dry-run", false, "show the count and send nothing")++ assumeYes := fs.Bool("yes", false, "do not ask before importing")++ endpoint := fs.String("endpoint", memorystore.DefaultBaseURL, "the memory store")++ tokenEnv := fs.String("token-env", memorystore.DefaultTokenEnv, "the variable the access token is read from")++ 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, memoryImportUsage)++ return 2++ }++ var raw []byte++ var err error++ if *file == "-" {++ raw, err = io.ReadAll(io.LimitReader(stdin, 64<<20))++ } else {++ raw, err = os.ReadFile(*file)++ }++ if err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ var portable memoryPortable++ if err := json.Unmarshal(raw, &portable); err != nil || portable.Format == "" {++ fmt.Fprintln(stderr, "error: that is not a portable memory file")++ return 1++ }++ if portable.Format != "openvibe.memory-export@1" || portable.Version != 1 {++ fmt.Fprintf(stderr, "error: the file is %s version %d; this Node reads openvibe.memory-export@1 version 1\n", portable.Format, portable.Version)++ return 1++ }++ fmt.Fprintf(stdout, "Portable memory file: %d notes, exported %s.\n", len(portable.Items), portable.ExportedAt)++ if *dryRun {++ fmt.Fprintln(stdout, "Nothing was sent: this was a dry run.")++ return 0++ }++ if len(portable.Items) == 0 {++ fmt.Fprintln(stdout, "Nothing to import.")++ return 0++ }++ if !*assumeYes && !confirmed(stdin, stdout, len(portable.Items)) {++ fmt.Fprintln(stdout, "\nNothing was imported.")++ return 0++ }++ token := strings.TrimSpace(os.Getenv(*tokenEnv))++ if token == "" {++ fmt.Fprintf(stderr, "error: %s is not set: it must hold your OpenVibe access token\n", *tokenEnv)++ return 1++ }++ client, err := memorystore.New(memorystore.Options{BaseURL: *endpoint, Token: token})++ if err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ res, err := client.Import(context.Background(), bytes.TrimSpace(raw))++ if err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ fmt.Fprintf(stdout, "\nImported: %d new, %d already there, %d skipped.\n", res.Created, res.Deduplicated, res.Skipped)++ return 0++}nnew file mode 100644@@@ -0,0 +1,246 @@++package main++++import (++ "bytes"++ "encoding/json"++ "io"++ "net/http"++ "net/http/httptest"++ "os"++ "path/filepath"++ "runtime"++ "strings"++ "sync"++ "testing"++)++++const portableFixture = `{"format":"openvibe.memory-export@1","version":1,"exported_at":"2026-11-07T09:30:00Z","items":[++ {"text":"Prefers short answers.","kind":"preference","scope":"user","source":"claude-code","status":"active","confidence":1},++ {"text":"Never rewrite history on main.","kind":"project_rule","scope":"project:demo","source":"agents","status":"active","confidence":1}++]}`++++// portableStore is a fake store for the export and import commands: it answers the two portable routes and records++// the person's token, the method and the body.++type portableStore struct {++ mu sync.Mutex++ requests []struct {++ method string++ path string++ query string++ auth string++ body []byte++ }++ exportBody string++ importBody string++}++++func newPortableStore(t *testing.T) (*portableStore, *httptest.Server) {++ t.Helper()++ ps := &portableStore{exportBody: portableFixture, importBody: `{"imported":2,"created":1,"deduped":1,"skipped":0}`}++ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ raw, _ := io.ReadAll(r.Body)++ ps.mu.Lock()++ ps.requests = append(ps.requests, struct {++ method string++ path string++ query string++ auth string++ body []byte++ }{r.Method, r.URL.Path, r.URL.RawQuery, r.Header.Get("Authorization"), raw})++ answer := ps.importBody++ if r.URL.Path == "/api/v1/memory/export" {++ answer = ps.exportBody++ }++ ps.mu.Unlock()++ w.Header().Set("Content-Type", "application/json")++ _, _ = w.Write([]byte(answer))++ }))++ t.Cleanup(srv.Close)++ return ps, srv++}++++func (ps *portableStore) last() (method, path, query, auth string, body []byte) {++ ps.mu.Lock()++ defer ps.mu.Unlock()++ if len(ps.requests) == 0 {++ return "", "", "", "", nil++ }++ r := ps.requests[len(ps.requests)-1]++ return r.method, r.path, r.query, r.auth, r.body++}++++func TestMemoryExportWritesThePortableFile(t *testing.T) {++ ps, srv := newPortableStore(t)++ t.Setenv("OPENVIBE_TOKEN", "the-person-token")++ dir := t.TempDir()++ out := filepath.Join(dir, "memory.json")++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"export", "--out", out, "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)++ if code != 0 {++ t.Fatalf("export exited %d: %s", code, stderr.String())++ }++ if !strings.Contains(stdout.String(), "Wrote 2 notes") {++ t.Fatalf("stdout: %s", stdout.String())++ }++ raw, err := os.ReadFile(out)++ if err != nil {++ t.Fatal(err)++ }++ if string(raw) != portableFixture {++ t.Fatalf("the file is not the store's answer: %s", raw)++ }++ info, err := os.Stat(out)++ if err != nil {++ t.Fatal(err)++ }++ // Windows reports a fixed 0666 for an ordinary file; the 0600 promise is a Unix mode.++ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {++ t.Fatalf("the portable file is %o, not 0600", info.Mode().Perm())++ }++ method, path, query, auth, _ := ps.last()++ if method != http.MethodGet || path != "/api/v1/memory/export" || query != "" {++ t.Fatalf("the store saw %s %s?%s", method, path, query)++ }++ if auth != "Bearer the-person-token" {++ t.Fatalf("the store saw auth %q", auth)++ }++ if strings.Contains(stdout.String(), "the-person-token") {++ t.Fatal("the token was printed")++ }++}++++func TestMemoryExportRefusesToOverwriteWithoutForce(t *testing.T) {++ _, srv := newPortableStore(t)++ t.Setenv("OPENVIBE_TOKEN", "the-person-token")++ dir := t.TempDir()++ out := filepath.Join(dir, "memory.json")++ if err := os.WriteFile(out, []byte("keep me"), 0o600); err != nil {++ t.Fatal(err)++ }++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"export", "--out", out, "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)++ if code == 0 {++ t.Fatal("an existing file was replaced without --force")++ }++ raw, _ := os.ReadFile(out)++ if string(raw) != "keep me" {++ t.Fatalf("the file was replaced: %s", raw)++ }++ stdout.Reset()++ stderr.Reset()++ code = cmdMemory([]string{"export", "--out", out, "--force", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)++ if code != 0 {++ t.Fatalf("--force exited %d: %s", code, stderr.String())++ }++}++++func TestMemoryExportScopeReachesTheStore(t *testing.T) {++ ps, srv := newPortableStore(t)++ t.Setenv("OPENVIBE_TOKEN", "the-person-token")++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"export", "--out", "-", "--scope", "project:demo", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)++ if code != 0 {++ t.Fatalf("export exited %d: %s", code, stderr.String())++ }++ _, _, query, _, _ := ps.last()++ if query != "scope=project%3Ademo" {++ t.Fatalf("query: %s", query)++ }++ if !strings.Contains(stdout.String(), `"openvibe.memory-export@1"`) {++ t.Fatalf("stdout did not carry the file: %s", stdout.String())++ }++}++++func TestMemoryImportAsksAndSendsTheFile(t *testing.T) {++ ps, srv := newPortableStore(t)++ t.Setenv("OPENVIBE_TOKEN", "the-person-token")++ dir := t.TempDir()++ in := filepath.Join(dir, "memory.json")++ if err := os.WriteFile(in, []byte(portableFixture), 0o600); err != nil {++ t.Fatal(err)++ }++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"import", "--file", in, "--endpoint", srv.URL}, strings.NewReader("n\n"), &stdout, &stderr)++ if code != 0 {++ t.Fatalf("import exited %d: %s", code, stderr.String())++ }++ if !strings.Contains(stdout.String(), "Nothing was imported") {++ t.Fatalf("a no did not stop the import: %s", stdout.String())++ }++ if method, _, _, _, _ := ps.last(); method != "" {++ t.Fatalf("the store was called after a no: %s", method)++ }++++ stdout.Reset()++ stderr.Reset()++ code = cmdMemory([]string{"import", "--file", in, "--yes", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)++ if code != 0 {++ t.Fatalf("import --yes exited %d: %s", code, stderr.String())++ }++ if !strings.Contains(stdout.String(), "1 new, 1 already there") {++ t.Fatalf("stdout: %s", stdout.String())++ }++ method, path, _, auth, body := ps.last()++ if method != http.MethodPost || path != "/api/v1/memory/import" {++ t.Fatalf("the store saw %s %s", method, path)++ }++ if auth != "Bearer the-person-token" {++ t.Fatalf("auth: %s", auth)++ }++ var doc map[string]any++ if err := json.Unmarshal(body, &doc); err != nil || doc["format"] != "openvibe.memory-export@1" {++ t.Fatalf("the body is not the portable file: %s", body)++ }++}++++func TestMemoryImportDryRunSendsNothing(t *testing.T) {++ ps, srv := newPortableStore(t)++ t.Setenv("OPENVIBE_TOKEN", "the-person-token")++ dir := t.TempDir()++ in := filepath.Join(dir, "memory.json")++ if err := os.WriteFile(in, []byte(portableFixture), 0o600); err != nil {++ t.Fatal(err)++ }++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"import", "--file", in, "--dry-run", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)++ if code != 0 {++ t.Fatalf("dry run exited %d: %s", code, stderr.String())++ }++ if !strings.Contains(stdout.String(), "2 notes") {++ t.Fatalf("stdout: %s", stdout.String())++ }++ if method, _, _, _, _ := ps.last(); method != "" {++ t.Fatalf("a dry run called the store: %s", method)++ }++}++++func TestMemoryImportRefusesAnotherFormatBeforeTheStore(t *testing.T) {++ ps, srv := newPortableStore(t)++ t.Setenv("OPENVIBE_TOKEN", "the-person-token")++ dir := t.TempDir()++ in := filepath.Join(dir, "not-memory.json")++ if err := os.WriteFile(in, []byte(`{"format":"something.else@1","version":1,"items":[]}`), 0o600); err != nil {++ t.Fatal(err)++ }++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"import", "--file", in, "--yes", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)++ if code == 0 {++ t.Fatal("another format was imported")++ }++ if method, _, _, _, _ := ps.last(); method != "" {++ t.Fatalf("the store was called for another format: %s", method)++ }++}++++func TestMemoryUsageNamesExportAndImport(t *testing.T) {++ var stdout, stderr bytes.Buffer++ if code := cmdMemory([]string{"help"}, strings.NewReader(""), &stdout, &stderr); code != 2 {++ t.Fatalf("help exited %d", code)++ }++ usage := stderr.String()++ for _, want := range []string{"memory export", "memory import", "--out FILE", "--file FILE"} {++ if !strings.Contains(usage, want) {++ t.Fatalf("the usage does not name %q:\n%s", want, usage)++ }++ }++}@@@ -25,6 +25,8 @@ 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]++openvibe-node memory export [--out FILE] [--scope SCOPE] [--force]++openvibe-node memory import --file FILE [--dry-run] [--yes]```| Flag | What it does |@@@ -32,12 +34,16 @@ openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]| `--tools a,b` | only these adapters (default: all) || `--project DIR` | read this project as well; may be given more than once || `--json` | print the items as JSON (scan) |--| `--dry-run` | print the summary and send nothing (upload) |--| `--yes` | do not ask before sending (upload) |++| `--dry-run` | print the summary and send nothing (upload, import) |++| `--yes` | do not ask before sending (upload, import) || `--show` | print the items themselves (status) || `--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) |++| `--out FILE` | export: where to write the portable file (`-` for stdout; default `openvibe-memory-YYYY-MM-DD.json`) |++| `--scope SCOPE` | export: only `user` or one `project:<slug>` |++| `--force` | export: replace a file that already exists |++| `--file FILE` | import: the portable file (`-` for stdin) || `--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 |@@@ -431,3 +437,28 @@ 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 thatwere written and whether the first diff was confirmed: paths and choices, neverthe memory text.++++## The portable file++++```sh++openvibe-node memory export [--out FILE] [--scope SCOPE] [--force]++openvibe-node memory import --file FILE [--dry-run] [--yes]++```++++`memory export` writes the store's one portable file, `openvibe.memory-export@1`++(see `OpenVibe.AI/docs/memory-m5.md` and the `ai.memory-export@1` contract): a++JSON document with every note's text, kind, scope, source, status, confidence++and source reference, and deliberately no account id, note id, merge link or++conflict pair. The file is written mode 0600, and an existing file is not++replaced unless `--force` is given; `--out -` writes it to stdout.++++`memory import` reads the same file back into the store. It refuses another++format before calling the store, shows how many notes the file holds, and asks++unless `--yes` is given (`--dry-run` shows the count and sends nothing). The++store redacts every item again, skips an item that is only a secret, imports a++`conflict` item as a suggestion (its pair did not travel), and merges a note++that says the same thing as one already there by its text MAC.++++The token is the same `OPENVIBE_TOKEN` the upload uses; it is never printed and++never written to the file. The file itself is the person's plaintext memory:++treat it like a password export.@@@ -361,6 +361,70 @@ func (c *Client) List(ctx context.Context, scope, query string) (ListResult, errreturn ListResult{Items: res.Items, Count: res.Count}, nil}++// PortableResult is what the store answered about a portable import.++type PortableResult struct {++ // Imported is how many items the store took (new plus already there).++ Imported int++ // Created is how many were new.++ Created int++ // Deduplicated is how many were already there, by their text.++ Deduplicated int++ // Skipped is how many the file carried but the store would not take.++ Skipped int++}++++// Export gives the one portable memory file, exactly as the store wrote it.++// The body is the person's own plaintext, so it is never logged.++func (c *Client) Export(ctx context.Context, scope string) ([]byte, error) {++ path := "/api/v1/memory/export"++ if scope != "" {++ path += "?scope=" + url.QueryEscape(scope)++ }++ raw, status, err := c.do(ctx, http.MethodGet, path, nil)++ if err != nil {++ return nil, err++ }++ if status/100 != 2 {++ return nil, c.apiError(status, raw)++ }++ var probe struct {++ Format string `json:"format"`++ }++ if err := json.Unmarshal(raw, &probe); err != nil || probe.Format == "" {++ return nil, errors.New("the memory store answered something that is not a portable memory file")++ }++ return raw, nil++}++++// Import sends a portable memory file back to the store, under the person's++// own token. The store validates the format, redacts every item again and++// dedupes by its text MAC; a file it refuses is an APIError.++func (c *Client) Import(ctx context.Context, document []byte) (PortableResult, error) {++ if len(document) == 0 {++ return PortableResult{}, errors.New("the file is empty")++ }++ if !json.Valid(document) {++ return PortableResult{}, errors.New("the file is not JSON")++ }++ raw, status, err := c.do(ctx, http.MethodPost, "/api/v1/memory/import", document)++ if err != nil {++ return PortableResult{}, err++ }++ if status/100 != 2 {++ return PortableResult{}, c.apiError(status, raw)++ }++ var res struct {++ Imported int `json:"imported"`++ Created int `json:"created"`++ Deduped int `json:"deduped"`++ Skipped int `json:"skipped"`++ }++ if err := json.Unmarshal(raw, &res); err != nil {++ return PortableResult{}, errors.New("the memory store answered something that is not JSON")++ }++ return PortableResult{Imported: res.Imported, Created: res.Created, Deduplicated: res.Deduped, Skipped: res.Skipped}, nil++}++// do sends one request with the person's token and gives back the body and the// status. A network failure is an error that names the cause, never the token.func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {@@@ -385,7 +449,8 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byreturn nil, 0, fmt.Errorf("the memory store could not be reached: %s", scrub(err.Error(), c.token))}defer resp.Body.Close()-- raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))++ // A portable file may be large (10,000 items); everything else is small.++ raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))if err != nil {return nil, resp.StatusCode, fmt.Errorf("the memory store answered badly: %w", err)}
@@ -16,6 +16,8 @@ const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--
@@ -16,6 +16,8 @@ const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--
"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" +
"openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes] | --disable a,b | --undo\n" +
+ "openvibe-node memory export [--out FILE] [--scope SCOPE] [--force]\n" +
+ "openvibe-node memory import --file FILE [--dry-run] [--yes]\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" +
@@ -28,15 +30,20 @@ const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--
@@ -28,15 +30,20 @@ const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--
" CLAUDE.md, GEMINI.md, Cursor rules and Copilot instructions,\n" +
" CLAUDE.md, GEMINI.md, Cursor rules and Copilot instructions,\n" +
" inside one marked block per file, after showing the diff. Each\n" +
" inside one marked block per file, after showing the diff. Each\n" +
" tool is off until you turn it on with --tools.\n" +
" tool is off until you turn it on with --tools.\n" +
+ " export writes your one portable memory file (JSON) from the store.\n" +
+ " import reads that same file back into your store, merging repeats.\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" +
" command-code, opencode)\n" +
" command-code, opencode)\n" +
" --project DIR read this project as well; may be given more than once\n" +
" --project DIR read this project as well; may be given more than once\n" +
" --json print the items as JSON (scan)\n" +
" --json print the items as JSON (scan)\n" +
- " --dry-run print the summary and send nothing (upload)\n" +
- " --yes do not ask before sending (upload)\n" +
+ " --dry-run print the summary and send nothing (upload, import)\n" +
+ " --yes do not ask before sending (upload, import)\n" +
" --show print the items themselves (status)\n" +
" --show print the items themselves (status)\n" +
+ " --out FILE where to write the portable file (export; - for stdout)\n" +
+ " --scope SCOPE only one scope (export)\n" +
+ " --file FILE the portable file to import (- for stdin)\n" +
" --user-home DIR read this directory as the person's home (default: yours)\n"
" --user-home DIR read this directory as the person's home (default: yours)\n"
// stringList is a flag that may be given more than once.
// stringList is a flag that may be given more than once.
@@ -67,6 +74,10 @@ func cmdMemory(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
@@ -67,6 +74,10 @@ func cmdMemory(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return cmdMemoryStatus(args[1:], stdout, stderr)
return cmdMemoryStatus(args[1:], stdout, stderr)
case "sync":
case "sync":
return cmdMemorySync(args[1:], stdin, stdout, stderr)
return cmdMemorySync(args[1:], stdin, stdout, stderr)
+ case "export":
+ return cmdMemoryExport(args[1:], stdout, stderr)
+ case "import":
+ return cmdMemoryImport(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,186 @@
@@ -0,0 +1,186 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/memorystore"
+)
+
+// memoryExportUsage is what "openvibe-node memory export" prints.
+const memoryExportUsage = "openvibe-node memory export [--out FILE] [--scope SCOPE] [--force]\n" +
+ "\n" +
+ " Writes your one portable memory file: a JSON document with every note, its\n" +
+ " kind, scope, source and confidence, and no account id or note id. The same\n" +
+ " file can be imported into another OpenVibe account.\n" +
+ "\n" +
+ " --out FILE where to write it (default openvibe-memory-YYYY-MM-DD.json;\n" +
+ " - writes to stdout)\n" +
+ " --scope SCOPE only one scope: user or project:<slug>\n" +
+ " --force replace the file if it already exists\n" +
+ " --endpoint URL the store (default " + memorystore.DefaultBaseURL + ")\n" +
+ " --token-env NAME the variable the access token is read from (default " + memorystore.DefaultTokenEnv + ")\n"
+
+// memoryImportUsage is what "openvibe-node memory import" prints.
+const memoryImportUsage = "openvibe-node memory import --file FILE [--dry-run] [--yes]\n" +
+ "\n" +
+ " Reads a portable memory file back into your store. The store validates the\n" +
+ " format, removes secrets again and merges a note that says the same thing as\n" +
+ " one you already have; it never copies the same text twice.\n" +
+ "\n" +
+ " --file FILE the file to import (- reads stdin)\n" +
+ " --dry-run show how many notes the file holds, and send nothing\n" +
+ " --yes do not ask before importing\n" +
+ " --endpoint URL the store (default " + memorystore.DefaultBaseURL + ")\n" +
+ " --token-env NAME the variable the access token is read from (default " + memorystore.DefaultTokenEnv + ")\n"
+
+// memoryPortable is the part of the portable format this command reads for its
+// summary: the format id, its version, and the notes.
+type memoryPortable struct {
+ Format string `json:"format"`
+ Version int `json:"version"`
+ ExportedAt string `json:"exported_at"`
+ Items []json.RawMessage `json:"items"`
+}
+
+// cmdMemoryExport writes the one portable memory file.
+func cmdMemoryExport(args []string, stdout, stderr io.Writer) int {
+ fs := flag.NewFlagSet("memory export", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ fs.Usage = func() { fmt.Fprint(stderr, memoryExportUsage) }
+ out := fs.String("out", "", "where to write the file")
+ scope := fs.String("scope", "", "only one scope")
+ force := fs.Bool("force", false, "replace an existing 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")
+ 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, memoryExportUsage)
+ return 2
+ }
+ token := strings.TrimSpace(os.Getenv(*tokenEnv))
+ if token == "" {
+ fmt.Fprintf(stderr, "error: %s is not set: it must hold your OpenVibe access token\n", *tokenEnv)
+ return 1
+ }
+ client, err := memorystore.New(memorystore.Options{BaseURL: *endpoint, Token: token})
+ if err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ doc, err := client.Export(context.Background(), *scope)
+ if err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ var portable memoryPortable
+ if err := json.Unmarshal(doc, &portable); err != nil {
+ fmt.Fprintln(stderr, "error: the store answered something that is not a portable file")
+ return 1
+ }
+ target := *out
+ if target == "" {
+ target = "openvibe-memory-" + time.Now().Format("2006-01-02") + ".json"
+ }
+ if target == "-" {
+ if _, err := stdout.Write(doc); err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ fmt.Fprintf(stderr, "Wrote %d notes to stdout.\n", len(portable.Items))
+ return 0
+ }
+ if !*force {
+ if _, err := os.Stat(target); err == nil {
+ fmt.Fprintf(stderr, "error: %s already exists; pass --force to replace it\n", target)
+ return 1
+ }
+ }
+ // The file is the person's own memory: only they may read it.
+ if err := os.WriteFile(target, doc, 0o600); err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ fmt.Fprintf(stdout, "Wrote %d notes to %s.\n", len(portable.Items), target)
+ return 0
+}
+
+// cmdMemoryImport reads a portable memory file back into the store.
+func cmdMemoryImport(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ fs := flag.NewFlagSet("memory import", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ fs.Usage = func() { fmt.Fprint(stderr, memoryImportUsage) }
+ file := fs.String("file", "-", "the file to import")
+ dryRun := fs.Bool("dry-run", false, "show the count and send nothing")
+ assumeYes := fs.Bool("yes", false, "do not ask before importing")
+ endpoint := fs.String("endpoint", memorystore.DefaultBaseURL, "the memory store")
+ tokenEnv := fs.String("token-env", memorystore.DefaultTokenEnv, "the variable the access token is read from")
+ 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, memoryImportUsage)
+ return 2
+ }
+ var raw []byte
+ var err error
+ if *file == "-" {
+ raw, err = io.ReadAll(io.LimitReader(stdin, 64<<20))
+ } else {
+ raw, err = os.ReadFile(*file)
+ }
+ if err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ var portable memoryPortable
+ if err := json.Unmarshal(raw, &portable); err != nil || portable.Format == "" {
+ fmt.Fprintln(stderr, "error: that is not a portable memory file")
+ return 1
+ }
+ if portable.Format != "openvibe.memory-export@1" || portable.Version != 1 {
+ fmt.Fprintf(stderr, "error: the file is %s version %d; this Node reads openvibe.memory-export@1 version 1\n", portable.Format, portable.Version)
+ return 1
+ }
+ fmt.Fprintf(stdout, "Portable memory file: %d notes, exported %s.\n", len(portable.Items), portable.ExportedAt)
+ if *dryRun {
+ fmt.Fprintln(stdout, "Nothing was sent: this was a dry run.")
+ return 0
+ }
+ if len(portable.Items) == 0 {
+ fmt.Fprintln(stdout, "Nothing to import.")
+ return 0
+ }
+ if !*assumeYes && !confirmed(stdin, stdout, len(portable.Items)) {
+ fmt.Fprintln(stdout, "\nNothing was imported.")
+ return 0
+ }
+ token := strings.TrimSpace(os.Getenv(*tokenEnv))
+ if token == "" {
+ fmt.Fprintf(stderr, "error: %s is not set: it must hold your OpenVibe access token\n", *tokenEnv)
+ return 1
+ }
+ client, err := memorystore.New(memorystore.Options{BaseURL: *endpoint, Token: token})
+ if err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ res, err := client.Import(context.Background(), bytes.TrimSpace(raw))
+ if err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ fmt.Fprintf(stdout, "\nImported: %d new, %d already there, %d skipped.\n", res.Created, res.Deduplicated, res.Skipped)
+ return 0
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+)
+
+const portableFixture = `{"format":"openvibe.memory-export@1","version":1,"exported_at":"2026-11-07T09:30:00Z","items":[
+ {"text":"Prefers short answers.","kind":"preference","scope":"user","source":"claude-code","status":"active","confidence":1},
+ {"text":"Never rewrite history on main.","kind":"project_rule","scope":"project:demo","source":"agents","status":"active","confidence":1}
+]}`
+
+// portableStore is a fake store for the export and import commands: it answers the two portable routes and records
+// the person's token, the method and the body.
+type portableStore struct {
+ mu sync.Mutex
+ requests []struct {
+ method string
+ path string
+ query string
+ auth string
+ body []byte
+ }
+ exportBody string
+ importBody string
+}
+
+func newPortableStore(t *testing.T) (*portableStore, *httptest.Server) {
+ t.Helper()
+ ps := &portableStore{exportBody: portableFixture, importBody: `{"imported":2,"created":1,"deduped":1,"skipped":0}`}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ ps.mu.Lock()
+ ps.requests = append(ps.requests, struct {
+ method string
+ path string
+ query string
+ auth string
+ body []byte
+ }{r.Method, r.URL.Path, r.URL.RawQuery, r.Header.Get("Authorization"), raw})
+ answer := ps.importBody
+ if r.URL.Path == "/api/v1/memory/export" {
+ answer = ps.exportBody
+ }
+ ps.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(answer))
+ }))
+ t.Cleanup(srv.Close)
+ return ps, srv
+}
+
+func (ps *portableStore) last() (method, path, query, auth string, body []byte) {
+ ps.mu.Lock()
+ defer ps.mu.Unlock()
+ if len(ps.requests) == 0 {
+ return "", "", "", "", nil
+ }
+ r := ps.requests[len(ps.requests)-1]
+ return r.method, r.path, r.query, r.auth, r.body
+}
+
+func TestMemoryExportWritesThePortableFile(t *testing.T) {
+ ps, srv := newPortableStore(t)
+ t.Setenv("OPENVIBE_TOKEN", "the-person-token")
+ dir := t.TempDir()
+ out := filepath.Join(dir, "memory.json")
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"export", "--out", out, "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("export exited %d: %s", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "Wrote 2 notes") {
+ t.Fatalf("stdout: %s", stdout.String())
+ }
+ raw, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(raw) != portableFixture {
+ t.Fatalf("the file is not the store's answer: %s", raw)
+ }
+ info, err := os.Stat(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Windows reports a fixed 0666 for an ordinary file; the 0600 promise is a Unix mode.
+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
+ t.Fatalf("the portable file is %o, not 0600", info.Mode().Perm())
+ }
+ method, path, query, auth, _ := ps.last()
+ if method != http.MethodGet || path != "/api/v1/memory/export" || query != "" {
+ t.Fatalf("the store saw %s %s?%s", method, path, query)
+ }
+ if auth != "Bearer the-person-token" {
+ t.Fatalf("the store saw auth %q", auth)
+ }
+ if strings.Contains(stdout.String(), "the-person-token") {
+ t.Fatal("the token was printed")
+ }
+}
+
+func TestMemoryExportRefusesToOverwriteWithoutForce(t *testing.T) {
+ _, srv := newPortableStore(t)
+ t.Setenv("OPENVIBE_TOKEN", "the-person-token")
+ dir := t.TempDir()
+ out := filepath.Join(dir, "memory.json")
+ if err := os.WriteFile(out, []byte("keep me"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"export", "--out", out, "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)
+ if code == 0 {
+ t.Fatal("an existing file was replaced without --force")
+ }
+ raw, _ := os.ReadFile(out)
+ if string(raw) != "keep me" {
+ t.Fatalf("the file was replaced: %s", raw)
+ }
+ stdout.Reset()
+ stderr.Reset()
+ code = cmdMemory([]string{"export", "--out", out, "--force", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("--force exited %d: %s", code, stderr.String())
+ }
+}
+
+func TestMemoryExportScopeReachesTheStore(t *testing.T) {
+ ps, srv := newPortableStore(t)
+ t.Setenv("OPENVIBE_TOKEN", "the-person-token")
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"export", "--out", "-", "--scope", "project:demo", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("export exited %d: %s", code, stderr.String())
+ }
+ _, _, query, _, _ := ps.last()
+ if query != "scope=project%3Ademo" {
+ t.Fatalf("query: %s", query)
+ }
+ if !strings.Contains(stdout.String(), `"openvibe.memory-export@1"`) {
+ t.Fatalf("stdout did not carry the file: %s", stdout.String())
+ }
+}
+
+func TestMemoryImportAsksAndSendsTheFile(t *testing.T) {
+ ps, srv := newPortableStore(t)
+ t.Setenv("OPENVIBE_TOKEN", "the-person-token")
+ dir := t.TempDir()
+ in := filepath.Join(dir, "memory.json")
+ if err := os.WriteFile(in, []byte(portableFixture), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"import", "--file", in, "--endpoint", srv.URL}, strings.NewReader("n\n"), &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("import exited %d: %s", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "Nothing was imported") {
+ t.Fatalf("a no did not stop the import: %s", stdout.String())
+ }
+ if method, _, _, _, _ := ps.last(); method != "" {
+ t.Fatalf("the store was called after a no: %s", method)
+ }
+
+ stdout.Reset()
+ stderr.Reset()
+ code = cmdMemory([]string{"import", "--file", in, "--yes", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("import --yes exited %d: %s", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "1 new, 1 already there") {
+ t.Fatalf("stdout: %s", stdout.String())
+ }
+ method, path, _, auth, body := ps.last()
+ if method != http.MethodPost || path != "/api/v1/memory/import" {
+ t.Fatalf("the store saw %s %s", method, path)
+ }
+ if auth != "Bearer the-person-token" {
+ t.Fatalf("auth: %s", auth)
+ }
+ var doc map[string]any
+ if err := json.Unmarshal(body, &doc); err != nil || doc["format"] != "openvibe.memory-export@1" {
+ t.Fatalf("the body is not the portable file: %s", body)
+ }
+}
+
+func TestMemoryImportDryRunSendsNothing(t *testing.T) {
+ ps, srv := newPortableStore(t)
+ t.Setenv("OPENVIBE_TOKEN", "the-person-token")
+ dir := t.TempDir()
+ in := filepath.Join(dir, "memory.json")
+ if err := os.WriteFile(in, []byte(portableFixture), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"import", "--file", in, "--dry-run", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("dry run exited %d: %s", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "2 notes") {
+ t.Fatalf("stdout: %s", stdout.String())
+ }
+ if method, _, _, _, _ := ps.last(); method != "" {
+ t.Fatalf("a dry run called the store: %s", method)
+ }
+}
+
+func TestMemoryImportRefusesAnotherFormatBeforeTheStore(t *testing.T) {
+ ps, srv := newPortableStore(t)
+ t.Setenv("OPENVIBE_TOKEN", "the-person-token")
+ dir := t.TempDir()
+ in := filepath.Join(dir, "not-memory.json")
+ if err := os.WriteFile(in, []byte(`{"format":"something.else@1","version":1,"items":[]}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"import", "--file", in, "--yes", "--endpoint", srv.URL}, strings.NewReader(""), &stdout, &stderr)
+ if code == 0 {
+ t.Fatal("another format was imported")
+ }
+ if method, _, _, _, _ := ps.last(); method != "" {
+ t.Fatalf("the store was called for another format: %s", method)
+ }
+}
+
+func TestMemoryUsageNamesExportAndImport(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ if code := cmdMemory([]string{"help"}, strings.NewReader(""), &stdout, &stderr); code != 2 {
+ t.Fatalf("help exited %d", code)
+ }
+ usage := stderr.String()
+ for _, want := range []string{"memory export", "memory import", "--out FILE", "--file FILE"} {
+ if !strings.Contains(usage, want) {
+ t.Fatalf("the usage does not name %q:\n%s", want, usage)
+ }
+ }
+}
@@ -25,6 +25,8 @@ openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
@@ -25,6 +25,8 @@ 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]
openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]
+openvibe-node memory export [--out FILE] [--scope SCOPE] [--force]
+openvibe-node memory import --file FILE [--dry-run] [--yes]
```
```
| Flag | What it does |
| Flag | What it does |
@@ -32,12 +34,16 @@ openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]
@@ -32,12 +34,16 @@ openvibe-node memory sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]
| `--tools a,b` | only these adapters (default: all) |
| `--tools a,b` | only these adapters (default: all) |
| `--project DIR` | read this project as well; may be given more than once |
| `--project DIR` | read this project as well; may be given more than once |
| `--json` | print the items as JSON (scan) |
| `--json` | print the items as JSON (scan) |
-| `--dry-run` | print the summary and send nothing (upload) |
-| `--yes` | do not ask before sending (upload) |
+| `--dry-run` | print the summary and send nothing (upload, import) |
+| `--yes` | do not ask before sending (upload, import) |
| `--show` | print the items themselves (status) |
| `--show` | print the items themselves (status) |
| `--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) |
+| `--out FILE` | export: where to write the portable file (`-` for stdout; default `openvibe-memory-YYYY-MM-DD.json`) |
+| `--scope SCOPE` | export: only `user` or one `project:<slug>` |
+| `--force` | export: replace a file that already exists |
+| `--file FILE` | import: the portable file (`-` for stdin) |
| `--tools a,b` | sync: turn these tools on |
| `--tools a,b` | sync: turn these tools on |
| `--disable a,b` | sync: turn these tools off and remove their blocks |
| `--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 |
| `--undo` | sync: remove every block this command wrote and turn the tools off |
@@ -431,3 +437,28 @@ The opt-in state lives in the Node's state directory
@@ -431,3 +437,28 @@ 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
(`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
were written and whether the first diff was confirmed: paths and choices, never
the memory text.
the memory text.
+
+## The portable file
+
+```sh
+openvibe-node memory export [--out FILE] [--scope SCOPE] [--force]
+openvibe-node memory import --file FILE [--dry-run] [--yes]
+```
+
+`memory export` writes the store's one portable file, `openvibe.memory-export@1`
+(see `OpenVibe.AI/docs/memory-m5.md` and the `ai.memory-export@1` contract): a
+JSON document with every note's text, kind, scope, source, status, confidence
+and source reference, and deliberately no account id, note id, merge link or
+conflict pair. The file is written mode 0600, and an existing file is not
+replaced unless `--force` is given; `--out -` writes it to stdout.
+
+`memory import` reads the same file back into the store. It refuses another
+format before calling the store, shows how many notes the file holds, and asks
+unless `--yes` is given (`--dry-run` shows the count and sends nothing). The
+store redacts every item again, skips an item that is only a secret, imports a
+`conflict` item as a suggestion (its pair did not travel), and merges a note
+that says the same thing as one already there by its text MAC.
+
+The token is the same `OPENVIBE_TOKEN` the upload uses; it is never printed and
+never written to the file. The file itself is the person's plaintext memory:
+treat it like a password export.
@@ -361,6 +361,70 @@ func (c *Client) List(ctx context.Context, scope, query string) (ListResult, err
@@ -361,6 +361,70 @@ func (c *Client) List(ctx context.Context, scope, query string) (ListResult, err
return ListResult{Items: res.Items, Count: res.Count}, nil
return ListResult{Items: res.Items, Count: res.Count}, nil
}
}
+// PortableResult is what the store answered about a portable import.
+type PortableResult struct {
+ // Imported is how many items the store took (new plus already there).
+ Imported int
+ // Created is how many were new.
+ Created int
+ // Deduplicated is how many were already there, by their text.
+ Deduplicated int
+ // Skipped is how many the file carried but the store would not take.
+ Skipped int
+}
+
+// Export gives the one portable memory file, exactly as the store wrote it.
+// The body is the person's own plaintext, so it is never logged.
+func (c *Client) Export(ctx context.Context, scope string) ([]byte, error) {
+ path := "/api/v1/memory/export"
+ if scope != "" {
+ path += "?scope=" + url.QueryEscape(scope)
+ }
+ raw, status, err := c.do(ctx, http.MethodGet, path, nil)
+ if err != nil {
+ return nil, err
+ }
+ if status/100 != 2 {
+ return nil, c.apiError(status, raw)
+ }
+ var probe struct {
+ Format string `json:"format"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil || probe.Format == "" {
+ return nil, errors.New("the memory store answered something that is not a portable memory file")
+ }
+ return raw, nil
+}
+
+// Import sends a portable memory file back to the store, under the person's
+// own token. The store validates the format, redacts every item again and
+// dedupes by its text MAC; a file it refuses is an APIError.
+func (c *Client) Import(ctx context.Context, document []byte) (PortableResult, error) {
+ if len(document) == 0 {
+ return PortableResult{}, errors.New("the file is empty")
+ }
+ if !json.Valid(document) {
+ return PortableResult{}, errors.New("the file is not JSON")
+ }
+ raw, status, err := c.do(ctx, http.MethodPost, "/api/v1/memory/import", document)
+ if err != nil {
+ return PortableResult{}, err
+ }
+ if status/100 != 2 {
+ return PortableResult{}, c.apiError(status, raw)
+ }
+ var res struct {
+ Imported int `json:"imported"`
+ Created int `json:"created"`
+ Deduped int `json:"deduped"`
+ Skipped int `json:"skipped"`
+ }
+ if err := json.Unmarshal(raw, &res); err != nil {
+ return PortableResult{}, errors.New("the memory store answered something that is not JSON")
+ }
+ return PortableResult{Imported: res.Imported, Created: res.Created, Deduplicated: res.Deduped, Skipped: res.Skipped}, nil
+}
+
// do sends one request with the person's token and gives back the body and the
// do sends one request with the person's token and gives back the body and the
// status. A network failure is an error that names the cause, never the token.
// status. A network failure is an error that names the cause, never the token.
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
@@ -385,7 +449,8 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
@@ -385,7 +449,8 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
return nil, 0, fmt.Errorf("the memory store could not be reached: %s", scrub(err.Error(), c.token))
return nil, 0, fmt.Errorf("the memory store could not be reached: %s", scrub(err.Error(), c.token))
}
}
defer resp.Body.Close()
defer resp.Body.Close()
- raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
+ // A portable file may be large (10,000 items); everything else is small.
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("the memory store answered badly: %w", err)
return nil, resp.StatusCode, fmt.Errorf("the memory store answered badly: %w", err)
}
}