openvibers/openvibe.link Commits

Memory M5: memory export and import, the portable file on the Node (#122)

OP OpenVibers committed 6 days ago · ea1ec2692c · 5 files, +544 −5

* Memory M5: memory export and import, the portable file on the Node

* Memory M5: the 0600 promise is a Unix mode (Windows reports 0666)

Changes

ddiff --git a/cmd/openvibe-node/memory.go b/cmd/openvibe-node/memory.go
iindex 81beddc..2d5f4aa 100644
---- a/cmd/openvibe-node/memory.go
++++ b/cmd/openvibe-node/memory.go
@@@ -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)
ddiff --git a/cmd/openvibe-node/memory_portable.go b/cmd/openvibe-node/memory_portable.go
nnew file mode 100644
iindex 0000000..acb8747
---- /dev/null
++++ b/cmd/openvibe-node/memory_portable.go
@@@ -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
++}
ddiff --git a/cmd/openvibe-node/memory_portable_test.go b/cmd/openvibe-node/memory_portable_test.go
nnew file mode 100644
iindex 0000000..c30e166
---- /dev/null
++++ b/cmd/openvibe-node/memory_portable_test.go
@@@ -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)
++ }
++ }
++}
ddiff --git a/docs/memory.md b/docs/memory.md
iindex 4fca2b2..626a505 100644
---- a/docs/memory.md
++++ b/docs/memory.md
@@@ -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 that
were written and whether the first diff was confirmed: paths and choices, never
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.
ddiff --git a/internal/memorystore/store.go b/internal/memorystore/store.go
iindex eefcb3c..0cf8c06 100644
---- a/internal/memorystore/store.go
++++ b/internal/memorystore/store.go
@@@ -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
}
++// 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) ([]by
return 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)
}