From 659961715461b4f428a276045593ebd6dd41dbea Mon Sep 17 00:00:00 2001 From: Texas-walker15 Date: Mon, 14 Sep 2026 14:59:23 +0200 Subject: [PATCH 1/4] feat: add privacy-preserving lightweight memory --- README.md | 44 +- docs/architecture.md | 17 +- docs/data-locations.md | 35 +- docs/phase-2-memory.md | 326 ++++++++ docs/security-model.md | 50 ++ src/main/index.ts | 8 + src/main/ipc.ts | 432 +++++++++- src/main/memory-picker.ts | 98 +++ src/main/memory-service.ts | 446 ++++++++++ src/main/memory-store.ts | 255 ++++++ src/main/memory-transfer.ts | 113 +++ src/main/paths.ts | 6 + src/preload/index.ts | 97 +++ src/renderer/App.tsx | 15 +- src/renderer/local-agent-bridge.d.ts | 31 + src/renderer/memory/Memory.tsx | 458 +++++++++++ src/renderer/memory/ipc-memory-client.ts | 138 ++++ src/renderer/memory/memory-controller.ts | 388 +++++++++ src/renderer/memory/useMemory.ts | 82 ++ src/renderer/styles.css | 144 ++++ src/shared/constants.ts | 218 +++++ src/shared/memory/errors.ts | 95 +++ src/shared/memory/index.ts | 29 + src/shared/memory/retrieval.ts | 208 +++++ src/shared/memory/secret-scan.ts | 160 ++++ src/shared/schemas/index.ts | 80 ++ src/shared/schemas/ipc.schema.ts | 167 ++++ src/shared/schemas/memory.schema.ts | 288 +++++++ src/shared/schemas/permissions.schema.ts | 40 + tests/e2e/electron-smoke.test.ts | 61 +- tests/unit/main/ipc-agent.test.ts | 6 + tests/unit/main/ipc-coding.test.ts | 6 + tests/unit/main/ipc-memory.test.ts | 762 ++++++++++++++++++ tests/unit/main/ipc.test.ts | 6 + tests/unit/main/memory-service.test.ts | 461 +++++++++++ tests/unit/main/memory-store.test.ts | 231 ++++++ tests/unit/main/settings-service.test.ts | 2 + tests/unit/renderer/memory-controller.test.ts | 419 ++++++++++ tests/unit/shared/chat-boundary-scan.test.ts | 15 +- tests/unit/shared/constants.test.ts | 79 ++ tests/unit/shared/memory-retrieval.test.ts | 220 +++++ tests/unit/shared/memory-secret-scan.test.ts | 113 +++ tests/unit/shared/memory.schema.test.ts | 290 +++++++ 43 files changed, 7113 insertions(+), 26 deletions(-) create mode 100644 docs/phase-2-memory.md create mode 100644 src/main/memory-picker.ts create mode 100644 src/main/memory-service.ts create mode 100644 src/main/memory-store.ts create mode 100644 src/main/memory-transfer.ts create mode 100644 src/renderer/memory/Memory.tsx create mode 100644 src/renderer/memory/ipc-memory-client.ts create mode 100644 src/renderer/memory/memory-controller.ts create mode 100644 src/renderer/memory/useMemory.ts create mode 100644 src/shared/memory/errors.ts create mode 100644 src/shared/memory/index.ts create mode 100644 src/shared/memory/retrieval.ts create mode 100644 src/shared/memory/secret-scan.ts create mode 100644 src/shared/schemas/memory.schema.ts create mode 100644 tests/unit/main/ipc-memory.test.ts create mode 100644 tests/unit/main/memory-service.test.ts create mode 100644 tests/unit/main/memory-store.test.ts create mode 100644 tests/unit/renderer/memory-controller.test.ts create mode 100644 tests/unit/shared/memory-retrieval.test.ts create mode 100644 tests/unit/shared/memory-secret-scan.test.ts create mode 100644 tests/unit/shared/memory.schema.test.ts diff --git a/README.md b/README.md index 7b1b436..6a6b9c3 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,23 @@ assistant is named **JARVIS** by default; the product is **Local Agent**. > [docs/phase-2-coding-actions.md](docs/phase-2-coding-actions.md) for the > design, the bounds, and what remains explicitly deferred (file creation and > deletion, arbitrary commands, dependency installation, browser and Windows -> automation, memory, and everything else Phase 2 has not reached yet). +> automation, and everything else Phase 2 has not reached yet). +> +> **Phase 2, Milestone 8 adds local memory.** Short notes you write about how +> you want to be worked with, in three scopes: `session`, held in memory for +> one run of the application and never written to disk; `project`, kept in a +> file addressed by a hash of the approved project's own root path, so one +> project's notes are in a file another project's session never opens; and +> `personal`. Nothing here grants anything — a memory record declares no field +> for a permission, a tool or a path, and nothing reads one out of its text — +> and **nothing here can be written by a model**: a record's `source` is an +> enum of `user` and `import`, both stamped by the main process, so there is no +> value a chat reply or an agent step could be stored under. Retrieval is a +> bounded keyword scan returning at most eight records, never the store. See +> [docs/phase-2-memory.md](docs/phase-2-memory.md) for the design, the +> credential screen and its stated limits, and what is deliberately absent +> (vector RAG, embeddings, cloud sync, telemetry, transcript storage and +> automatic inference). All rights reserved. No licence has been granted for this project. @@ -198,7 +214,10 @@ in [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md), and [docs/phase-2-provider-completion.md](docs/phase-2-provider-completion.md); the workspace design is in -[docs/phase-2-coding-workspace.md](docs/phase-2-coding-workspace.md). +[docs/phase-2-coding-workspace.md](docs/phase-2-coding-workspace.md); the +agent profile and orchestration design is in +[docs/phase-2-agent-profiles.md](docs/phase-2-agent-profiles.md); and the +memory design is in [docs/phase-2-memory.md](docs/phase-2-memory.md). ## Where your data lives @@ -208,7 +227,9 @@ lives outside it, under `%APPDATA%\Local-Agent\`, each in its own location. `settings.json` and the encrypted `secrets\secrets.enc` are now reachable from the running application through real, permission-gated IPC channels; permission policy and emergency-stop state are still loaded read-only at -startup, with no channel of their own yet. See +startup, with no channel of their own yet. Since Milestone 8, `memory\` holds +one file per persisted scope — never a credential, and never a session note, +which is held in memory and written nowhere. See [docs/data-locations.md](docs/data-locations.md). ## Requirements @@ -302,9 +323,14 @@ src/main/ Privileged Electron main process. Owns the BrowserWindow, file; workspace-planner.ts), agent profiles and the bounded orchestrator (agent-profiles.ts — fail-safe, atomic storage that never persists a built-in; agent-orchestrator.ts — the - run loop, which decides nothing about permissions), and the - registered IPC channels (ipc.ts), of which chat:chunk is the - only main-to-renderer event. + run loop, which decides nothing about permissions), local + memory (memory-store.ts — fail-safe, atomic storage, with the + session scope held in memory and never written; + memory-service.ts — the scoped operations, which never learn + a path; memory-transfer.ts and memory-picker.ts — files + outside the application, at a path only a native dialog can + choose), and the registered IPC channels (ipc.ts), of which + chat:chunk is the only main-to-renderer event. src/preload/ The single contextBridge. Exposes a narrow, explicitly enumerated, typed API — never ipcRenderer, never a generic invoke-any-channel function. Bundled into one file: a @@ -324,7 +350,11 @@ src/renderer/ React interface: App.tsx gates on onboardingCompleted, call window.localAgent), and agent/ is the Milestone 7 profile and run surface (Agents.tsx, the framework-independent agent-controller.ts, useAgent.ts, and ipc-agent-client.ts — - the third and last file permitted to call window.localAgent). + the third file permitted to call window.localAgent), and + memory/ is the Milestone 8 Memory Centre (Memory.tsx, the + framework-independent memory-controller.ts, useMemory.ts, and + ipc-memory-client.ts — the fourth and last file permitted to + call window.localAgent). No Node, no Electron, no direct filesystem or network access anywhere else in this directory — only the bridge at window.localAgent. diff --git a/docs/architecture.md b/docs/architecture.md index d53b11b..8661dce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,18 @@ > to an action type Milestones 5 and 6 already defined, so a profile can only > ever _narrow_ what the permission policy already allowed. The permission > engine, executor, pipeline and audit writer have **zero diff** for the sixth -> consecutive milestone. This document marks which parts exist today. +> consecutive milestone. Phase 2 Milestone 8 adds local memory +> (`main/memory-store.ts`, `main/memory-service.ts`, `main/memory-transfer.ts`, +> `main/memory-picker.ts`, over the pure `shared/memory/`) — ten more channels +> and five more action types, none of which is a new capability either: they +> read and write short user-authored notes inside `%APPDATA%\Local-Agent`, +> and a note grants nothing, because no field of one can name a permission, a +> tool or a path. Isolation between scopes is where the bytes are rather than +> a filter: session notes live only in memory and are never written, and a +> project's notes live in a file addressed by a hash of that project's own +> canonical root. The permission engine, executor, pipeline and audit writer +> have **zero diff** for the seventh consecutive milestone. This document marks +> which parts exist today. --- @@ -129,6 +140,10 @@ call. See `docs/phase-2-coding-workspace.md`. | `main/git-runner` **(exists)** | Read-only `status`/`diff`, plus one checkpoint commit on the branch already checked out. Hooks disabled for every invocation. | Reset, checkout, switch, clean, delete a branch, rewrite history, or contact a remote. | | `main/agent-profiles` **(exists)** | Loads, validates and atomically writes agent profiles. Fails closed to the built-ins. Never persists a built-in. | Store a credential; merge a partially-valid document; let a stored profile claim a built-in identifier. | | `main/agent-orchestrator` **(exists)** | Drives one bounded run: asks the pure decider what to do next, calls the injected step executor, classifies the result. | Decide that anything is permitted; call a model; reach the filesystem or a process itself. | +| `main/memory-store` **(exists)** | Loads, validates and atomically writes one memory scope. Fails closed to an empty store. Holds the session scope in memory, never on disk. | Take a project key or a path from a caller; merge a partially-valid document; write the session scope. | +| `main/memory-service` **(exists)** | The scoped memory operations, over an injected store accessor. Screens content for credentials; prunes expired records on write. | Learn which file a scope lives in; move a record between scopes; let a filesystem error escape. | +| `main/memory-transfer` **(exists)** | Reads an import file defensively and writes an export atomically, at a path the user chose in a native dialog. | Choose a path; claim an imported file is a memory document; leave a partial export behind. | +| `main/memory-picker` **(exists)** | The two native dialogs that gate memory content crossing the application boundary. | Take a path, a default directory or a suggestion from the renderer. | ## Dependency direction diff --git a/docs/data-locations.md b/docs/data-locations.md index 0020054..3d68dd1 100644 --- a/docs/data-locations.md +++ b/docs/data-locations.md @@ -35,7 +35,10 @@ user-supplied or renderer-supplied path. > (disengaged vs. engaged), not the same safe default — plus atomic engage > and reset writes. Milestone 7 implements `secrets\secrets.enc`: encrypted > with the **synchronous** `safeStorage` API (Windows DPAPI), never a -> plaintext fallback. Its loader distinguishes a missing file from a corrupt +> plaintext fallback. Phase 2 Milestone 8 implements `memory\`: one file per +> scope, each written with the same fail-safe load and atomic write pattern, +> plus the session scope which is never written at all. Its loader +> distinguishes a missing file from a corrupt > one internally, the same way `state\emergency.json`'s does, but — unlike > the emergency state — both collapse to the identical safe answer for every > caller (no key present), since there is no "fails engaged" equivalent for a @@ -67,18 +70,28 @@ user-supplied or renderer-supplied path. │ └── audit-YYYY-MM-DD.jsonl append-only audit trail, one file per UTC day ├── state\ │ └── emergency.json emergency-stop state -└── memory\ reserved, unused in Phase 1 +└── memory\ + ├── personal.json personal memory (never a credential) + └── projects\ + └── .json one file per approved project ``` -| Path | Contains | Notes | -| ------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings.json` | Assistant name, user name, language, provider selection, `hasApiKey` flag | **[implemented, M3, wired to IPC in M7]** **Never a credential.** Strict schema; unknown keys rejected; written atomically; `hasApiKey` reconciled against `secrets.enc` on every read and write. | -| `permissions\policy.json` | Permission rules | **[implemented, M5]** Human-readable and human-editable. Cannot widen the model beyond the code-enforced floor, even if hand-edited to try. | -| `agents\profiles.json` | Agent profiles | **[implemented, P2 M7]** Human-readable and human-editable. Holds user profiles only — built-ins are read from reviewed source on every load and are never written here, so a hand edit cannot redefine one. Cannot grant a permission: its decision vocabulary has no `allow`. Declares no field capable of holding a credential. | -| `secrets\secrets.enc` | API keys | **[implemented, M7]** Encrypted with the synchronous `safeStorage` API (Windows DPAPI). Never leaves the main process in plaintext; presence, not content, is all any IPC channel reports. | -| `logs\audit\` | One JSON object per line | **[implemented, M4]** Append-only writer, called for real since M7's IPC channels. Records denials and rejected confirmations exactly like a success. | -| `state\emergency.json` | Emergency-stop state | **[implemented, M6]** Missing file on first launch means _disengaged_. Malformed or unreadable existing file means _engaged_. Written atomically; not called from the running app yet. | -| `memory\` | Reserved | Nothing is written here in Phase 1. | +| Path | Contains | Notes | +| ---------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings.json` | Assistant name, user name, language, provider selection, `hasApiKey` flag | **[implemented, M3, wired to IPC in M7]** **Never a credential.** Strict schema; unknown keys rejected; written atomically; `hasApiKey` reconciled against `secrets.enc` on every read and write. | +| `permissions\policy.json` | Permission rules | **[implemented, M5]** Human-readable and human-editable. Cannot widen the model beyond the code-enforced floor, even if hand-edited to try. | +| `agents\profiles.json` | Agent profiles | **[implemented, P2 M7]** Human-readable and human-editable. Holds user profiles only — built-ins are read from reviewed source on every load and are never written here, so a hand edit cannot redefine one. Cannot grant a permission: its decision vocabulary has no `allow`. Declares no field capable of holding a credential. | +| `secrets\secrets.enc` | API keys | **[implemented, M7]** Encrypted with the synchronous `safeStorage` API (Windows DPAPI). Never leaves the main process in plaintext; presence, not content, is all any IPC channel reports. | +| `logs\audit\` | One JSON object per line | **[implemented, M4]** Append-only writer, called for real since M7's IPC channels. Records denials and rejected confirmations exactly like a success. | +| `state\emergency.json` | Emergency-stop state | **[implemented, M6]** Missing file on first launch means _disengaged_. Malformed or unreadable existing file means _engaged_. Written atomically; not called from the running app yet. | +| `memory\personal.json` | Personal memory records | **[implemented, P2 M8]** Human-readable and human-editable. Plain JSON, deliberately — but it holds notes _about a person_, so see `docs/security-model.md` limitations 0.5 and 0.6. Declares no field capable of holding a credential, and credential-shaped _values_ are screened at the write boundary on a best-effort basis. | +| `memory\projects\.json` | One approved project's memory records | **[implemented, P2 M8]** The file name is a truncated SHA-256 of the project's canonical root path, never the path itself: a directory listing of `%APPDATA%` should not disclose which projects someone has opened. Reading or writing one requires that project to be approved in the current session. | + +> **Session memory has no entry above, and that is the point.** The third +> memory scope is held in the main process for the lifetime of one run of the +> application and is never written anywhere. Closing Local Agent ends it; +> there is no file to find afterwards, and no code path in +> `src/main/memory-store.ts` that could create one. ## Why they are separate diff --git a/docs/phase-2-memory.md b/docs/phase-2-memory.md new file mode 100644 index 0000000..31a2b56 --- /dev/null +++ b/docs/phase-2-memory.md @@ -0,0 +1,326 @@ +# Phase 2, Milestone 8 — local memory + +Short notes the user writes about how they want to be worked with, stored on +their own machine, retrievable a handful at a time. + +This document explains what memory is here, what it deliberately is **not**, +and why each of those decisions was made. It complements +[architecture.md](architecture.md) and [security-model.md](security-model.md); +where they disagree with this file, they are the ones that were written to +cover the whole system and they win. + +--- + +## What a memory is + +A record with these fields, and no others: + +| Field | Meaning | +| ------------------------- | ------------------------------------------------------------------- | +| `id` | A UUID, assigned by the main process. | +| `scope` | `session`, `project` or `personal`. | +| `category` | One of seven closed values (see below). | +| `content` | The note itself. Up to 2 000 characters. | +| `source` | `user` or `import`. Stamped by the main process. | +| `createdAt` / `updatedAt` | UTC ISO-8601, from the main process's clock. | +| `importance` | 1–5. Used only for ordering. | +| `confidence` | 0–100. Used only for ordering. | +| `expiresAt` | A UTC ISO-8601 instant, or `null`. | +| `pinned` | "Always relevant". The user's own signal, and it outranks the rest. | + +The seven categories are the seven memory types the milestone asked for: +`user-preference`, `assistant-setting`, `project-decision`, +`project-convention`, `active-task`, `completed-task`, `agent-preference`. A +closed vocabulary rather than a free-text tag, because a category is +displayed, filtered on and used to break ties in retrieval, and a free-text +tag would be one more unbounded user-controlled string reaching all three. + +## What a memory is not + +**It is not authority.** A record declares no field for a permission, a tool, +an action type, a provider, a command or a path, and nothing anywhere in this +codebase reads one out of `content`. The string is stored, displayed and +matched against a search query; that is the complete list of things done with +it. No memory can widen what an agent may do, change a permission decision, or +name something to run. + +**It is not written by a model.** `source` has exactly two members, `user` and +`import`, and each is stamped by the one handler that performs that operation +— the input schema has no `source` field for a caller to supply. There is no +value a chat reply, an agent step or an inference could be stored under, so +"never silently save model output as memory" is a property of the type rather +than a check that could be forgotten. There is also no `memory.capture`, no +`memory.infer` and no `memory.learn` action type, and nothing in the chat or +agent paths calls a memory writer. + +**It is not a transcript.** Nothing stores a conversation. A record is a note +someone typed into a form and saved, capped at 2 000 characters, 200 records +per scope. + +**It is not a credential store.** No field of a record can carry one by name — +every object is a `strictObject`, so a document containing `apiKey`, `token` +or `password` is refused outright rather than stored and ignored. Values are a +separate and weaker matter; see "The credential screen" below. + +--- + +## The three scopes are three storage backends + +This is the part worth reading closely, because isolation here is a property +of _where the bytes are_ rather than of a filter someone has to remember to +apply. + +### `session` + +Held in a closure created per `registerIpcHandlers` call, exactly like the +approved-project session. **Never written to disk at all.** There is no +`session` branch in any function in `main/memory-store.ts` that touches the +filesystem, so this is not a policy that could be changed by a configuration +mistake — there is nowhere for a session note to go. Closing Local Agent ends +it. + +### `project` + +One file per approved project: + +``` +%APPDATA%\Local-Agent\memory\projects\.json +``` + +`` is the first 32 hexadecimal characters of the SHA-256 of the project's +**canonical, lowercased** root path. Three consequences, each deliberate: + +- **The path is never stored.** A project path carries a user name, often a + client or employer name, and sometimes the existence of a project that is + itself confidential. A directory listing of `%APPDATA%` should not disclose + any of that. +- **The key is derived, never supplied.** No caller passes a path, a key or a + project name into the store module; `requireProjectMemoryFile()` in + `main/ipc.ts` reads the approved project from the session and derives the + name. There is no parameter through which one project's session could name + another project's file. +- **Lowercasing matters.** Windows paths are case-insensitive, so `C:\Work\App` + and `c:\work\app` are the same project, and two stores for it would silently + split someone's notes in half. + +Reading or writing this scope requires a project approved in the current +session. With none, every project operation fails with `MEMORY_NO_PROJECT` — +except a retrieval, which skips the scope rather than failing, because a +reminder is not worth refusing because one of three sources is unavailable. + +### `personal` + +One file, `%APPDATA%\Local-Agent\memory\personal.json`. The only scope that +outlives both the session and the project. + +### A record cannot change scope + +An update addresses a record by id _within_ the scope its own submitted record +names, and a stored record found in a different scope is refused rather than +relocated. There is no move operation. Editing a project note into a personal +one is therefore not expressible — which is what keeps project isolation from +depending on the renderer behaving. + +--- + +## Storage behaviour + +`main/memory-store.ts` mirrors `main/settings.ts`, `main/policy.ts` and +`main/agent-profiles.ts` in detail, because a memory file is the same _kind_ +of thing those are: a user-editable document the application must read without +trusting it. + +- **Loading never throws and never merges.** A missing file, an unreadable + one, an oversized one, malformed JSON, a `__proto__` key anywhere in it, a + document the schema rejects, or a document declaring a scope other than the + one asked for, all resolve the same way: an empty store. A document either + validates in full, as itself, or it is discarded in full. +- **The size check happens before the read.** A file that grew without bound + should not be pulled into the privileged process's memory to discover that + it is too large. +- **Writing is atomic and re-validated.** Checked against the schema + immediately before serialising, written to a uniquely named temporary file + in the same directory, flushed, and moved into place with a single rename. +- **Nothing is created by reading.** The `memory\` directory appears on the + first write and not before. + +### Expiry + +A record past its own `expiresAt` is filtered out of every list, search, +retrieval and export, and is dropped from the file on the next write to that +scope. **Reads never write**, so an expiry does not by itself cause disk +activity: the record disappears from every view immediately and leaves the +file the next time anything is saved. An `expiresAt` that will not parse +counts as _expired_, not as "no expiry" — treating unreadable state as "keep +using this indefinitely" is the wrong direction to fail in for a privacy +control. + +--- + +## Retrieval + +Bounded keyword retrieval, using the same `extractObjectiveKeywords` the +coding planner already uses — not a second extractor. The whole operation is a +substring scan over at most 200 short strings per scope. + +- A record is eligible if its content contains at least one keyword from the + objective, **or** if it is pinned. Pinning means "always relevant"; a pin + that only took effect when the objective happened to mention the right word + would not be a pin. +- Ordering is: relevance (a count of distinct matching keywords), then pinned, + then importance, then confidence, then recency, then a total tie-break on + `id`. Deterministic, so the behaviour is testable rather than incidentally + stable. +- Results are deduplicated _after_ ordering, by case- and + whitespace-insensitive content, so the survivor of two identical notes is + the higher-ranked one. +- The result is capped at **8 records**, and `memoryRetrievalResultSchema` + cannot carry more — the milestone's rule that the whole store is never + handed to a model is enforced by the type, not only by the code that fills + it. + +There is no vector index, no embedding service and no background indexing +anywhere in this path, and no dependency was added for any of it. + +> **Nothing calls retrieval from a model path yet.** `memory:retrieve` exists, +> is permission-gated and is tested, but no chat or agent code path invokes +> it. It is the documented seam for a later milestone, not a working +> integration, and it is described that way rather than implied to be wired. + +--- + +## Security + +### Everything goes through the existing pipeline + +Five new action types, all routed through the unmodified +`handleActionProposal`: + +| Action | Decision | Why | +| --------------- | --------- | ---------------------------------------------------------------------------------------------- | +| `memory.read` | `allow` | Reading the user's own notes back to them changes nothing and leaves the machine nowhere. | +| `memory.write` | `allow` | Stores text the user typed, in the app's own data directory, reversible by the same operation. | +| `memory.clear` | `confirm` | Destroys a whole scope at once and cannot be undone. | +| `memory.export` | `confirm` | Writes notes to a file outside the application, where its protections no longer apply. | +| `memory.import` | `confirm` | Brings content from outside into a store. | + +The last three are on the **confirmation floor**, so no policy edit can +downgrade them to `allow`. The first two are deliberately not: a native dialog +for every note saved or unpinned would train people to click through dialogs, +which is its own security problem, and the closest existing precedent — +`settings.write`, which also writes user-typed data into the app's own data +directory — is not on the floor either. + +### The audit trail carries no private content + +Memory proposals record an operation name, a scope, a bounded enum and at most +a _length_. **No record content, no search query, no objective and no file +path enters an audit record.** The audit log is append-only and therefore +cannot be redacted afterwards, which is exactly why nothing private is written +into it in the first place. This is asserted directly in +`tests/unit/main/ipc-memory.test.ts`. + +### Export and import + +Neither channel takes a path. `memory:export` and `memory:import` carry a +scope and nothing else; the file is chosen by the user in a native dialog the +main process owns (`main/memory-picker.ts`), exactly as `workspace:select` +already works. A compromised renderer can ask that the user be asked, and +nothing more. + +The confirmation comes **before** the dialog, and declining it means the +dialog never opens at all. + +An **export document** carries the records and the time, and nothing about +where they came from: no path, no project name, no project key, no machine +name, no user name. An export is the one artefact of this application designed +to leave the machine. + +An **import file** is untrusted content: + +- size-checked before it is read; +- parsed as JSON with no reviver, then screened for a prototype-polluting key; +- returned as `unknown` — `main/memory-transfer.ts` never claims it is a + memory document; +- validated against `memoryExportSchema`, including the `kind` marker, so a + file that is merely valid JSON is refused as "not a memory export" rather + than probed field by field for something that happens to fit; +- required to declare the same scope it is being imported into, so someone's + personal notes cannot land in a project store by accident; +- re-stamped per record: a **fresh id**, so a crafted file cannot collide with + or address an existing record; `source: 'import'`, so the interface can + always show that a note came from outside; and a new `updatedAt`. + +The document is all-or-nothing; individual records may be refused while the +rest import, and the count is reported. A record is refused if its content +looks like a credential or if the scope already holds the same note. The +summary reports _how many_ were refused and never _why per record_ — a +per-record reason would be one more way for imported content to shape what is +displayed. + +### The credential screen + +`src/shared/memory/secret-scan.ts` runs at the write boundary — `add`, +`update` and `import` — and refuses content matching a recognisable credential +shape: a provider key with a known prefix, an `Authorization: Bearer` header +pasted out of a browser's network tab, a PEM private-key block, or a +credential-shaped label followed by a value (reusing `SECRET_FIELD_NAMES`, the +codebase's single source of truth for what a credential-bearing name looks +like). It also refuses a long, mixed-case, high-entropy token with no known +prefix. + +Two things about it matter: + +1. **It is not in the persisted-record schema, deliberately.** A loader that + started refusing documents on a rule added later would discard a whole file + of legitimate notes the next time the rule tightened. Screening at the + boundary keeps new content out without putting existing content at risk. +2. **It is best effort and is described that way everywhere.** A credential is + just a string; a short database password or a passphrase made of real words + is indistinguishable from a note by any local rule. The screen reduces + accidents. It is not a guarantee, and the Memory Centre says so on the + form. + +It reports a _category_ — `known-key-prefix`, `bearer-token`, `private-key`, +`labelled-secret`, `high-entropy-token` — and never the matched text. Echoing +back the fragment that looked like a secret would put the secret into an error +path, which is the one place it must never reach. + +### Errors carry a code and nothing else + +`MEMORY_ERROR_CODES` is a closed list. A failure crossing the IPC boundary +carries one of those codes and never a message, a path, a file name or the +content of a record. The sentences a person reads live in +`src/renderer/memory/memory-controller.ts`, in its own reviewed +`FAILURE_MESSAGES` table, and none of them interpolates anything. + +--- + +## What was deliberately not built + +Vector RAG; embedding services; cloud memory sync; telemetry; full transcript +storage; automatic personality inference; camera or screen memory; browser or +email memory; self-modifying permissions; multi-agent memory coordination; +unbounded background indexing. + +No dependency was added for this milestone. + +--- + +## Files + +**Shared (pure).** `shared/memory/errors.ts`, `shared/memory/secret-scan.ts`, +`shared/memory/retrieval.ts`, `shared/memory/index.ts`, +`shared/schemas/memory.schema.ts`. + +**Main.** `main/memory-store.ts` (where the bytes are), +`main/memory-service.ts` (the operations, over an injected accessor), +`main/memory-transfer.ts` (files outside the application), +`main/memory-picker.ts` (the two native dialogs). + +**Renderer.** `renderer/memory/ipc-memory-client.ts` — the single file in that +directory permitted to touch `window.localAgent`, asserted by +`tests/unit/shared/chat-boundary-scan.test.ts` — +`renderer/memory/memory-controller.ts` (framework-independent, where the +testable behaviour lives), `renderer/memory/useMemory.ts`, +`renderer/memory/Memory.tsx`. diff --git a/docs/security-model.md b/docs/security-model.md index 0024760..7cf3a33 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -98,9 +98,32 @@ cannot declare anything else. An action with no matching rule is denied. `secrets.clear`, `emergency.reset`, `app.exit`, and — since Phase 2 Milestone 6 — `workspace.write`, `workspace.rollback`, `command.run` and `git.checkpoint`, plus — since Milestone 7 — `agent.write` and `agent.run`, +and — since Milestone 8 — `memory.clear`, `memory.export` and `memory.import`, cannot be downgraded to `allow` by editing the policy file. The schema rejects such a file. +`memory.read` and `memory.write` are deliberately **not** on that floor. +Saving or unpinning one note is an ordinary edit inside the application's own +data directory, reversible by the same operation that made it — the same +reasoning that keeps `settings.write` off the floor. The three that are on it +each stop being ordinary: clearing destroys a whole scope at once, exporting +writes the user's notes to a file outside the application where its +protections no longer apply, and importing brings content from outside into a +store. Prompting for every note saved would train people to click through +dialogs, which is its own security problem. + +**[enforced by type]** A **memory can never be written by a model.** A memory +record's `source` is an enum of exactly two members, `user` and `import`, and +both are stamped in the main process by the one handler that performs each +operation — the input schema has no `source` field at all. There is no value a +chat reply, an agent step or an inference could be stored under, so "never +silently save model output as memory" is a property of the type rather than a +check somewhere that could be forgotten. In the same way, a memory record +declares no field for a permission, a tool, an action type, a provider, a +command or a path, and nothing in the codebase reads any of those out of its +content: a note is stored, displayed and matched against a search query, and +that is the entire set of things done with it. + **[enforced by type]** An **agent profile can never grant a permission.** A profile is user-editable configuration describing which of a fixed set of tools an agent may reach for, and its own decision vocabulary is @@ -833,6 +856,33 @@ These are real and are stated plainly rather than described as solved. it is recorded here rather than presented as equivalent to approving each action. +0.5. **A memory record's content is stored in plain text, and the credential +screen is best effort.** Phase 2 Milestone 8 keeps memory in +`memory\personal.json` and `memory\projects\.json`, both plain JSON +readable by anything running under the same Windows account. That is the +same exposure `settings.json` and `permissions\policy.json` already have, +and it is deliberate — these files are meant to be inspectable and +hand-editable — but memory holds notes _about a person_, which is a +different kind of content than a display name. Two consequences follow. +First, the store is not encrypted: DPAPI is reserved for the secret store, +where the whole point is that the content must never be readable. Second, +the value-level credential screen +(`src/shared/memory/secret-scan.ts`) catches _recognisable_ shapes — a +provider key with a known prefix, a pasted `Authorization: Bearer` header, +a PEM block, a `password=…` line — and cannot catch a credential that +looks like ordinary text. Someone determined to type a short database +password into a note will succeed. The control reduces accidents; it is not +a guarantee, and it is described that way in the interface as well as here. + +0.6. **Clearing or deleting a memory does not securely erase it.** Every write +is one atomic rename over the previous file, so the _application_ retains +nothing and no partial document is ever left behind — but the replaced +file's old blocks are not overwritten, and on a journalling or +copy-on-write filesystem, or with Volume Shadow Copy enabled, earlier +content may survive on the disk. No application can promise otherwise from +user space. A deleted note is gone from Local Agent; it is not guaranteed +to be gone from the drive. + 1. **The audit log is append-only by API, not tamper-proof.** A local user with the same privileges can edit the file directly with a text editor. Tamper-evidence (hash chaining or signing) is deferred beyond Phase 1. The diff --git a/src/main/index.ts b/src/main/index.ts index 3a01b83..847b077 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { showNativeConfirmation } from './confirm'; import { showDirectoryPicker } from './directory-picker'; import { loadEmergencyState } from './emergency'; import { registerIpcHandlers } from './ipc'; +import { showMemoryExportPicker, showMemoryImportPicker } from './memory-picker'; import { resolveUserDataPaths } from './paths'; import { loadPermissionPolicy } from './policy'; import { loadSettings } from './settings'; @@ -129,6 +130,13 @@ app // because this is the one file that imports the live `electron` module // and holds the real window to parent the dialog to. selectProjectDirectory: () => showDirectoryPicker(window.isDestroyed() ? null : window), + // The two dialogs that gate memory content crossing the application + // boundary (Phase 2, Milestone 8). Built here for the same reason as + // the picker above: this is the one file holding the live `electron` + // module and the real window to parent a dialog to. + selectMemoryExportFile: (scope) => + showMemoryExportPicker(window.isDestroyed() ? null : window, scope), + selectMemoryImportFile: () => showMemoryImportPicker(window.isDestroyed() ? null : window), nowFn: () => new Date().toISOString(), }); }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a8405b2..b6c7059 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -38,6 +38,26 @@ import { } from './agent-profiles'; import { describeAgentRun, runAgentOrchestration } from './agent-orchestrator'; import type { AgentStepOutcome, AgentStepRequest } from './agent-orchestrator'; +import { + addMemory, + buildMemoryExport, + clearMemoryScope, + deleteMemory, + importMemoryRecords, + listMemories, + retrieveRelevantMemories, + searchMemoryScope, + setMemoryPinned, + updateMemory, +} from './memory-service'; +import type { MemoryStoreAccess } from './memory-service'; +import { + createSessionMemoryStore, + loadMemoryStore, + resolveProjectMemoryFile, + writeMemoryStore, +} from './memory-store'; +import { readMemoryImportFile, writeMemoryExportFile } from './memory-transfer'; import { runAction } from './action-runtime'; import type { ActionRuntime } from './action-runtime'; import { resolveMainChatProvider } from './chat-provider-registry'; @@ -71,14 +91,17 @@ import type { AgentRegistry } from '../shared/agent/registry'; import { findAgentTool } from '../shared/agent/tools'; import { CHAT_PROVIDER_ERROR_CODES, ChatProviderError } from '../shared/chat/provider'; import type { ChatProviderResult } from '../shared/chat/provider'; +import { isMemoryErrorCode, MemoryError } from '../shared/memory/errors'; +import type { MemoryErrorCode } from '../shared/memory/errors'; import { AGENT_NAME_MAX_LENGTH, AGENT_STEP_SUMMARY_MAX_LENGTH, CHAT_STREAM_MAX_DELTA_LENGTH, COMMAND_MAX_CONCURRENT_RUNS, + MEMORY_RETRIEVAL_SCOPES, PROVIDERS_REQUIRING_API_KEY, } from '../shared/constants'; -import type { ModelProvider } from '../shared/constants'; +import type { MemoryScope, ModelProvider } from '../shared/constants'; import { describeCommandLine, findCodingCommand } from '../shared/workspace/command-registry'; import { collapseToSingleLine } from '../shared/workspace/text-safety'; import { @@ -180,6 +203,36 @@ import { IPC_AGENT_SELECT_CHANNEL, IPC_AGENT_SET_ENABLED_CHANNEL, IPC_AGENT_UPDATE_CHANNEL, + IPC_MEMORY_ADD_CHANNEL, + IPC_MEMORY_CLEAR_CHANNEL, + IPC_MEMORY_DELETE_CHANNEL, + IPC_MEMORY_EXPORT_CHANNEL, + IPC_MEMORY_IMPORT_CHANNEL, + IPC_MEMORY_LIST_CHANNEL, + IPC_MEMORY_RETRIEVE_CHANNEL, + IPC_MEMORY_SEARCH_CHANNEL, + IPC_MEMORY_SET_PINNED_CHANNEL, + IPC_MEMORY_UPDATE_CHANNEL, + memoryAddRequestSchema, + memoryAddResponseSchema, + memoryClearRequestSchema, + memoryClearResponseSchema, + memoryDeleteRequestSchema, + memoryDeleteResponseSchema, + memoryExportRequestSchema, + memoryExportResponseSchema, + memoryImportRequestSchema, + memoryImportResponseSchema, + memoryListRequestSchema, + memoryListResponseSchema, + memoryRetrieveRequestSchema, + memoryRetrieveResponseSchema, + memorySearchRequestSchema, + memorySearchResponseSchema, + memorySetPinnedRequestSchema, + memorySetPinnedResponseSchema, + memoryUpdateRequestSchema, + memoryUpdateResponseSchema, } from '../shared/schemas'; import type { AgentProfile, @@ -200,6 +253,14 @@ import type { GitDiffValue, GitStatusResponse, GitStatusValue, + MemoryMutationResponse, + MemoryMutationSummary, + MemoryQueryResponse, + MemoryQueryResult, + MemoryRecord, + MemoryRecordResponse, + MemoryRetrievalResult, + MemoryRetrieveResponse, SecretsActionResponse, SecretStatusResult, SettingsActionResponse, @@ -270,6 +331,24 @@ export interface IpcHandlerRuntime { * renderer sends can influence which directory is offered or chosen. */ readonly selectProjectDirectory: () => Promise; + /** + * Shows the native save dialog for a memory export and resolves to what the + * user chose, or `null` if they dismissed it (Phase 2, Milestone 8). + * + * Injected for the same reason `selectProjectDirectory` is, and narrow for + * the same reason: the only thing the caller may influence is the scope + * name in the suggested file name, which comes from an enum. The renderer + * cannot name a file to overwrite. + */ + readonly selectMemoryExportFile: (scope: MemoryScope) => Promise; + /** + * Shows the native open dialog for a memory import (Phase 2, Milestone 8). + * + * Takes no parameter at all: nothing the renderer sends can influence which + * file is offered or chosen, and the file that comes back is still treated + * as untrusted content by everything downstream. + */ + readonly selectMemoryImportFile: () => Promise; } /** @@ -540,6 +619,65 @@ function toAgentRunResponse(result: ActionResult): AgentRunResponse { }; } +/** + * The same defence in depth the workspace and agent vocabularies get, for the + * memory vocabulary (Phase 2, Milestone 8). + * + * A memory `perform` only ever throws a `MemoryError` translated to an + * `ActionExecutionError` carrying the same code, but `execute` has its own + * generic `EXECUTION_FAILED` for an unexpected throw, and that is not a + * member of this vocabulary. Degrading it keeps a response schema-valid + * rather than letting an internal code — whose text this milestone has not + * reviewed — cross into the renderer. + */ +function toMemoryErrorCode( + result: ActionResult, + fallback: MemoryErrorCode, +): MemoryErrorCode | undefined { + if (result.errorCode === undefined) return undefined; + return isMemoryErrorCode(result.errorCode) ? result.errorCode : fallback; +} + +function toMemoryQueryResponse(result: ActionResult): MemoryQueryResponse { + const errorCode = toMemoryErrorCode(result, 'MEMORY_READ_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { result: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toMemoryRetrieveResponse( + result: ActionResult, +): MemoryRetrieveResponse { + const errorCode = toMemoryErrorCode(result, 'MEMORY_READ_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { result: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toMemoryRecordResponse(result: ActionResult): MemoryRecordResponse { + const errorCode = toMemoryErrorCode(result, 'MEMORY_STORE_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { record: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toMemoryMutationResponse( + result: ActionResult, +): MemoryMutationResponse { + const errorCode = toMemoryErrorCode(result, 'MEMORY_STORE_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { summary: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + /** * How much of a run's output budget one step's result consumed. * @@ -1711,4 +1849,296 @@ export function registerIpcHandlers(ipcMain: IpcMain, runtime: IpcHandlerRuntime inFlightAgentRuns.get(runId)?.abort(); return agentCancelResponseSchema.parse({ acknowledged: true }); }); + + // ------------------------------------------------------------------------- + // Local memory (Phase 2, Milestone 8) + // ------------------------------------------------------------------------- + + /** + * The session scope, held for the lifetime of this handler registration. + * + * Created per `registerIpcHandlers` call, exactly like + * {@link inFlightChatRequests} and the approved-project session, and for the + * same reason: one test's session notes can never leak into another's, and + * one application run's notes never survive into the next. Nothing writes + * this to disk — there is no branch below that could. + */ + const sessionMemory = createSessionMemoryStore(); + + /** + * Where one project's notes live, or a refusal. + * + * This is the whole of project isolation, and it is deliberately one + * function: the file name is *derived* from the canonically-resolved root + * of the project approved in this session, never supplied by a caller. + * There is no parameter anywhere in the memory channels through which a + * renderer could name a different project's store, because there is no + * parameter here at all. + */ + function requireProjectMemoryFile(): string { + const project = workspaceSession.get(); + if (project === null) throw new MemoryError('MEMORY_NO_PROJECT'); + return resolveProjectMemoryFile(runtime.userDataPaths.memoryProjectsDir, project.rootPath); + } + + /** + * The one place that knows which backing store a scope has. + * + * `main/memory-service.ts` is given this and never learns a path, which is + * what keeps "session memory is never written to disk" and "a project's + * notes are in that project's file" properties of one reviewed object + * rather than habits every operation has to keep. + */ + const memoryAccess: MemoryStoreAccess = { + read: (scope) => { + if (scope === 'session') return Promise.resolve(sessionMemory.read()); + if (scope === 'personal') { + return loadMemoryStore(runtime.userDataPaths.memoryPersonalFile, 'personal'); + } + return loadMemoryStore(requireProjectMemoryFile(), 'project'); + }, + write: (scope, store) => { + if (scope === 'session') { + sessionMemory.write(store); + return Promise.resolve(); + } + if (scope === 'personal') { + return writeMemoryStore(runtime.userDataPaths.memoryPersonalFile, store); + } + return writeMemoryStore(requireProjectMemoryFile(), store); + }, + }; + + /** + * Runs one memory operation as a permission-gated, audited action. + * + * The same shape as {@link runAgentAction}, differing only in which + * normalized error vocabulary it translates. + * + * The `parameters` every caller passes below are deliberately thin — an + * operation name, a scope, a bounded enum, and at most a *length*. **No + * record content, no search query, no objective and no file path ever + * enters an audit record.** The audit trail is meant to answer "what did + * this application do", and for memory that question is answerable without + * repeating the user's private notes into a second file that is + * append-only and therefore cannot be redacted afterwards. + */ + async function runMemoryAction( + actionType: ActionType, + parameters: Record, + perform: (now: string) => TValue | Promise, + buildConfirmation?: (now: string) => string | Promise, + ): Promise> { + const now = runtime.nowFn(); + const actionRuntime = buildActionRuntime(runtime, now); + const confirmationMessage = + buildConfirmation === undefined ? null : await buildConfirmation(now); + + return runAction( + actionRuntime, + newProposal(actionType, parameters), + confirmationMessage, + async () => { + try { + return await perform(now); + } catch (error) { + if (error instanceof MemoryError) { + throw new ActionExecutionError(error.code, error.message); + } + throw error; + } + }, + ); + } + + /** Plain English for a scope, for the two dialogs that name one. */ + function describeScope(scope: MemoryScope): string { + if (scope === 'session') return 'this session’s memory (never written to disk)'; + if (scope === 'project') return 'this project’s memory'; + return 'your personal memory'; + } + + /** + * How many records a scope currently holds, or `null` if that cannot be + * determined right now. + * + * The failure is swallowed deliberately: this only feeds a sentence in a + * confirmation dialog, and a dialog that cannot state a count should say so + * rather than turning a countable operation into an error before the user + * has even been asked. The real failure still surfaces — `perform` reads the + * same store and throws the normalized code. + */ + async function countMemories(scope: MemoryScope): Promise { + try { + return (await memoryAccess.read(scope)).records.length; + } catch { + return null; + } + } + + ipcMain.handle(IPC_MEMORY_LIST_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope }] = memoryListRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.read', + { operation: 'list', scope }, + (now) => listMemories(memoryAccess, scope, now), + ); + + return memoryListResponseSchema.parse(toMemoryQueryResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_SEARCH_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope, query }] = memorySearchRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.read', + // The query's length, never the query. What someone searched their own + // notes for is exactly as private as the notes. + { operation: 'search', scope, queryLength: query.length }, + (now) => searchMemoryScope(memoryAccess, scope, query, now), + ); + + return memorySearchResponseSchema.parse(toMemoryQueryResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_RETRIEVE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ objective }] = memoryRetrieveRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.read', + { operation: 'retrieve', objectiveLength: objective.length }, + // Spans every readable scope, and is capped at `MEMORY_MAX_RETRIEVED` + // by the service — the response schema could not carry the whole store + // even if this asked for it. + (now) => retrieveRelevantMemories(memoryAccess, MEMORY_RETRIEVAL_SCOPES, objective, now), + ); + + return memoryRetrieveResponseSchema.parse(toMemoryRetrieveResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_ADD_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ record }] = memoryAddRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.write', + { + operation: 'add', + scope: record.scope, + category: record.category, + contentLength: record.content.length, + }, + // `source: 'user'` is stamped inside `addMemory`, never taken from the + // request — the input schema has no such field. + (now) => addMemory(memoryAccess, record, now, randomUUID), + ); + + return memoryAddResponseSchema.parse(toMemoryRecordResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_UPDATE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ id, record }] = memoryUpdateRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.write', + { + operation: 'update', + scope: record.scope, + category: record.category, + contentLength: record.content.length, + }, + (now) => updateMemory(memoryAccess, id, record, now), + ); + + return memoryUpdateResponseSchema.parse(toMemoryRecordResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_SET_PINNED_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ id, scope, pinned }] = memorySetPinnedRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.write', + { operation: 'set-pinned', scope, pinned }, + (now) => setMemoryPinned(memoryAccess, id, scope, pinned, now), + ); + + return memorySetPinnedResponseSchema.parse(toMemoryRecordResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_DELETE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ id, scope }] = memoryDeleteRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.write', + { operation: 'delete', scope }, + (now) => deleteMemory(memoryAccess, id, scope, now), + ); + + return memoryDeleteResponseSchema.parse(toMemoryMutationResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_CLEAR_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope }] = memoryClearRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.clear', + { operation: 'clear', scope }, + (now) => clearMemoryScope(memoryAccess, scope, now), + async () => { + const count = await countMemories(scope); + const subject = count === null ? 'every record' : `all ${String(count)} record(s)`; + return `Delete ${subject} in ${describeScope(scope)}? This cannot be undone.`; + }, + ); + + return memoryClearResponseSchema.parse(toMemoryMutationResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_EXPORT_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope }] = memoryExportRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.export', + { operation: 'export', scope }, + async (now) => { + const document = await buildMemoryExport(memoryAccess, scope, now); + // The file is chosen *after* the confirmation and by the user, in a + // native dialog this process owns. The renderer never sees a path and + // never supplies one. + const target = await runtime.selectMemoryExportFile(scope); + if (target === null) throw new MemoryError('MEMORY_FILE_SELECTION_CANCELLED'); + await writeMemoryExportFile(target, document); + return { scope, affected: document.records.length, rejected: 0 }; + }, + async () => { + const count = await countMemories(scope); + const subject = count === null ? 'the records' : `${String(count)} record(s)`; + return `Write ${subject} from ${describeScope(scope)} to a file outside Local Agent? You will choose the file next. Once exported, this application’s protections no longer apply to them.`; + }, + ); + + return memoryExportResponseSchema.parse(toMemoryMutationResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_IMPORT_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope }] = memoryImportRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.import', + { operation: 'import', scope }, + async (now) => { + const source = await runtime.selectMemoryImportFile(); + if (source === null) throw new MemoryError('MEMORY_FILE_SELECTION_CANCELLED'); + // Returned as `unknown`: nothing has yet claimed this file is a + // memory export. `importMemoryRecords` decides that, against the + // schema, and re-stamps every record it accepts. + const document = await readMemoryImportFile(source); + return importMemoryRecords(memoryAccess, scope, document, now, randomUUID); + }, + () => + `Read memory records from a file outside Local Agent into ${describeScope(scope)}? You will choose the file next. Its contents are untrusted: every record is validated, and anything that looks like a credential is refused.`, + ); + + return memoryImportResponseSchema.parse(toMemoryMutationResponse(result)); + }); } diff --git a/src/main/memory-picker.ts b/src/main/memory-picker.ts new file mode 100644 index 0000000..7cf8df0 --- /dev/null +++ b/src/main/memory-picker.ts @@ -0,0 +1,98 @@ +/** + * The native memory export and import dialogs (Phase 2, Milestone 8). + * + * The counterpart to `main/directory-picker.ts`, and native for the same + * reason: these two dialogs are the *consent* for memory content crossing the + * application boundary in either direction. The renderer must not be able to + * choose the file, pre-fill it, forge the dialog, or dismiss it on the user's + * behalf. + * + * So the renderer supplies nothing but a scope. Neither function takes a + * path, and there is no default directory, no remembered location and no + * suggested one beyond a plain file name: whatever the dialog returns is what + * the user actually clicked on. A compromised renderer can ask that the user + * be asked, and nothing more. + * + * `properties` is deliberately minimal on both. The open dialog has + * `openFile` alone — no `multiSelections` (one file at a time) and no + * `openDirectory` (there is nothing here that reads a tree). The save dialog + * gets `showOverwriteConfirmation`, so overwriting an existing file is always + * the user's second, explicit decision. + */ + +import type { BrowserWindow, OpenDialogOptions, SaveDialogOptions } from 'electron'; +import { dialog } from 'electron'; + +import type { MemoryScope } from '../shared/constants'; + +/** + * A safe default file name for an export. + * + * Built from the scope enum and a fixed prefix — never from a project name, a + * path, or a record's content — so nothing user-controlled reaches a file + * name. The user can still type whatever they like in the dialog; this is + * only what it opens with. + */ +function defaultExportFileName(scope: MemoryScope): string { + return `local-agent-memory-${scope}.json`; +} + +const JSON_FILTERS = [{ name: 'Memory export', extensions: ['json'] }]; + +/** + * Shows the save dialog and resolves to the chosen file, or `null` when the + * user dismissed it. + * + * `null` is a first-class answer, not an error: cancelling is the user + * declining, and the caller turns it into the dedicated + * `MEMORY_FILE_SELECTION_CANCELLED` code rather than a generic failure. + */ +export async function showMemoryExportPicker( + window: BrowserWindow | null, + scope: MemoryScope, +): Promise { + const options: SaveDialogOptions = { + title: 'Local Agent — export memory', + buttonLabel: 'Export', + defaultPath: defaultExportFileName(scope), + message: 'These notes will be written outside Local Agent, where its protections do not apply.', + filters: JSON_FILTERS, + properties: ['showOverwriteConfirmation', 'createDirectory'], + }; + + const result = window + ? await dialog.showSaveDialog(window, options) + : await dialog.showSaveDialog(options); + + if (result.canceled) return null; + // Electron types `filePath` as a plain string here, unlike the open + // dialog's array, so an empty string is the only "nothing chosen" case + // left once `canceled` is false. + return result.filePath.length === 0 ? null : result.filePath; +} + +/** + * Shows the open dialog and resolves to the chosen file, or `null`. + * + * The returned path is *not* trusted as a memory export on the strength of + * having come from here — `main/memory-transfer.ts` still size-checks it, + * parses it defensively and hands back `unknown`, and the service still + * validates it against the schema. A dialog result is still input. + */ +export async function showMemoryImportPicker(window: BrowserWindow | null): Promise { + const options: OpenDialogOptions = { + title: 'Local Agent — import memory', + buttonLabel: 'Import', + message: 'Local Agent will read memory records from this file. Its contents are untrusted.', + filters: JSON_FILTERS, + properties: ['openFile'], + }; + + const result = window + ? await dialog.showOpenDialog(window, options) + : await dialog.showOpenDialog(options); + + if (result.canceled) return null; + const [chosen] = result.filePaths; + return chosen === undefined || chosen.length === 0 ? null : chosen; +} diff --git a/src/main/memory-service.ts b/src/main/memory-service.ts new file mode 100644 index 0000000..3484201 --- /dev/null +++ b/src/main/memory-service.ts @@ -0,0 +1,446 @@ +/** + * Memory operations (Phase 2, Milestone 8). + * + * The layer between `main/ipc.ts`, which decides *whether* an operation may + * happen, and `main/memory-store.ts`, which knows where the bytes are. Every + * function here is given a {@link MemoryStoreAccess} rather than a path, so + * this module never learns which file a scope lives in — which is what keeps + * "project notes are isolated" a property of the accessor rather than of + * every operation remembering to check. + * + * Five rules hold across everything below: + * + * - **An expired record is never returned and never re-written.** Reads + * filter by expiry; writes persist the filtered set. Reads never write, so + * an expiry does not by itself cause disk activity — the record disappears + * from every view immediately and leaves the file at the next write to + * that scope. + * - **A record cannot change scope.** Every mutation addresses a record + * within one scope and refuses a record found in another. There is no + * "move" operation, and editing a project note into a personal one is not + * expressible. + * - **Credential-shaped content is refused at the boundary.** `add`, + * `update` and `import` run {@link findLikelySecret} before anything is + * written. The loader deliberately does not, so a rule tightened later + * cannot start discarding files of notes that were already stored. + * - **Nothing here reads a clock or generates an id.** Both are injected, as + * they are for every other main-process module in this codebase, so tests + * are deterministic and one IPC call has one timestamp throughout. + * - **A failure is a {@link MemoryError} and nothing else.** No filesystem + * error, no Zod issue and no fragment of a record's content escapes this + * module — the caller receives a code from a closed vocabulary. + */ + +import { + MEMORY_MAX_RECORDS_PER_SCOPE, + MEMORY_MAX_RETRIEVED, + MEMORY_MAX_SEARCH_RESULTS, + MEMORY_SCHEMA_VERSION, +} from '../shared/constants'; +import type { MemoryScope } from '../shared/constants'; +import { MemoryError } from '../shared/memory/errors'; +import { findLikelySecret } from '../shared/memory/secret-scan'; +import { + activeMemories, + orderMemories, + retrieveMemories, + searchMemories, +} from '../shared/memory/retrieval'; +import { + MEMORY_EXPORT_KIND, + memoryExportSchema, + memoryRecordSchema, +} from '../shared/schemas/memory.schema'; +import type { + MemoryExport, + MemoryMutationSummary, + MemoryQueryResult, + MemoryRecord, + MemoryRecordInput, + MemoryRetrievalResult, + MemoryStore, +} from '../shared/schemas/memory.schema'; + +/** + * How one scope's store is reached. + * + * Built in `main/ipc.ts` from the resolved user-data paths, the session store + * and the approved-project session. A project scope with no approved project + * throws `MEMORY_NO_PROJECT` from `read` — the refusal lives in the accessor, + * at the one place that knows which project is open, rather than being + * repeated in every operation. + */ +export interface MemoryStoreAccess { + read(scope: MemoryScope): Promise; + write(scope: MemoryScope, store: MemoryStore): Promise; +} + +/** The scopes a retrieval spans, in the order ties are broken. */ +export type ReadableScopes = readonly MemoryScope[]; + +function withRecords(store: MemoryStore, records: readonly MemoryRecord[]): MemoryStore { + return { schemaVersion: MEMORY_SCHEMA_VERSION, scope: store.scope, records: [...records] }; +} + +/** + * Persists `records` to `scope`, dropping anything already expired. + * + * Every write goes through here, so "an expired record leaves the file at the + * next write" is one line in one place rather than a habit each operation has + * to keep. + */ +async function commit( + access: MemoryStoreAccess, + store: MemoryStore, + records: readonly MemoryRecord[], + now: string, +): Promise { + const pruned = activeMemories(records, now); + try { + await access.write(store.scope, withRecords(store, pruned)); + } catch (error) { + // A `MemoryError` from the accessor — `MEMORY_NO_PROJECT`, say — is + // already normalized and says something true; anything else is a + // filesystem or validation failure whose message must not travel. + if (error instanceof MemoryError) throw error; + throw new MemoryError('MEMORY_STORE_FAILED'); + } +} + +/** Refuses content that looks like a credential before it can be stored. */ +function requireNoSecret(content: string): void { + if (findLikelySecret(content) !== null) throw new MemoryError('MEMORY_SECRET_REJECTED'); +} + +function findRecord(store: MemoryStore, id: string): MemoryRecord { + const record = store.records.find((entry) => entry.id === id); + if (record === undefined) throw new MemoryError('MEMORY_NOT_FOUND'); + return record; +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +/** + * One scope's records, most important and most recent first. + * + * Capped at {@link MEMORY_MAX_SEARCH_RESULTS} with `total` and `truncated` + * reported alongside, so a caller can always tell a complete answer from a + * capped one rather than drawing a conclusion from a silently trimmed list. + */ +export async function listMemories( + access: MemoryStoreAccess, + scope: MemoryScope, + now: string, +): Promise { + const store = await access.read(scope); + const active = orderMemories(activeMemories(store.records, now)); + return { + scope, + records: active.slice(0, MEMORY_MAX_SEARCH_RESULTS), + total: active.length, + truncated: active.length > MEMORY_MAX_SEARCH_RESULTS, + }; +} + +export async function searchMemoryScope( + access: MemoryStoreAccess, + scope: MemoryScope, + query: string, + now: string, +): Promise { + const store = await access.read(scope); + const outcome = searchMemories(store.records, query, now, MEMORY_MAX_SEARCH_RESULTS); + return { + scope, + records: [...outcome.records], + total: outcome.total, + truncated: outcome.truncated, + }; +} + +/** + * The small, relevant set — the shape a model would eventually be handed. + * + * Spans every scope the session can currently read, concatenated and then + * ranked together, so a highly relevant project note outranks an unrelated + * personal one rather than scope order deciding it. A scope that cannot be + * read right now (project, with nothing approved) is skipped rather than + * failing the whole retrieval: a reminder is not worth refusing because one + * of three sources is unavailable. + * + * Capped at {@link MEMORY_MAX_RETRIEVED}. The whole store is never returned + * from here, and the response schema could not carry it if it were. + */ +export async function retrieveRelevantMemories( + access: MemoryStoreAccess, + scopes: ReadableScopes, + objective: string, + now: string, +): Promise { + const collected: MemoryRecord[] = []; + for (const scope of scopes) { + try { + const store = await access.read(scope); + collected.push(...store.records); + } catch (error) { + if (error instanceof MemoryError && error.code === 'MEMORY_NO_PROJECT') continue; + throw error; + } + } + + const outcome = retrieveMemories(collected, objective, now, MEMORY_MAX_RETRIEVED); + return { records: [...outcome.records], considered: outcome.considered }; +} + +// --------------------------------------------------------------------------- +// Single-record writes +// --------------------------------------------------------------------------- + +/** + * Adds one record. + * + * `source` is stamped `'user'` here and nowhere else in this function's + * reach: the input schema has no such field, so a caller cannot claim its + * note came from an import — or, more importantly, cannot claim an imported + * record was typed by the person. + */ +export async function addMemory( + access: MemoryStoreAccess, + input: MemoryRecordInput, + now: string, + newId: () => string, +): Promise { + requireNoSecret(input.content); + + const store = await access.read(input.scope); + const active = activeMemories(store.records, now); + if (active.length >= MEMORY_MAX_RECORDS_PER_SCOPE) { + throw new MemoryError('MEMORY_LIMIT_REACHED'); + } + + const parsed = memoryRecordSchema.safeParse({ + ...input, + id: newId(), + source: 'user', + createdAt: now, + updatedAt: now, + }); + if (!parsed.success) throw new MemoryError('MEMORY_INVALID'); + + await commit(access, store, [...active, parsed.data], now); + return parsed.data; +} + +/** + * Replaces one record in place. + * + * `createdAt` and `source` are carried over from the stored record rather + * than taken from the request, so an edit can change neither when a note was + * written nor where it came from. The submitted scope must be the scope the + * record already lives in — a mismatch is a refusal, not a move. + */ +export async function updateMemory( + access: MemoryStoreAccess, + id: string, + input: MemoryRecordInput, + now: string, +): Promise { + requireNoSecret(input.content); + + const store = await access.read(input.scope); + const existing = findRecord(store, id); + if (existing.scope !== input.scope) throw new MemoryError('MEMORY_SCOPE_MISMATCH'); + + const parsed = memoryRecordSchema.safeParse({ + ...input, + id, + source: existing.source, + createdAt: existing.createdAt, + updatedAt: now, + }); + if (!parsed.success) throw new MemoryError('MEMORY_INVALID'); + + await commit( + access, + store, + store.records.map((entry) => (entry.id === id ? parsed.data : entry)), + now, + ); + return parsed.data; +} + +export async function setMemoryPinned( + access: MemoryStoreAccess, + id: string, + scope: MemoryScope, + pinned: boolean, + now: string, +): Promise { + const store = await access.read(scope); + const existing = findRecord(store, id); + + const updated: MemoryRecord = { ...existing, pinned, updatedAt: now }; + await commit( + access, + store, + store.records.map((entry) => (entry.id === id ? updated : entry)), + now, + ); + return updated; +} + +export async function deleteMemory( + access: MemoryStoreAccess, + id: string, + scope: MemoryScope, + now: string, +): Promise { + const store = await access.read(scope); + findRecord(store, id); + + await commit( + access, + store, + store.records.filter((entry) => entry.id !== id), + now, + ); + return { scope, affected: 1, rejected: 0 }; +} + +// --------------------------------------------------------------------------- +// Bulk operations +// --------------------------------------------------------------------------- + +/** + * Empties one scope. + * + * Writes an empty document rather than deleting the file, so the result is + * one atomic rename with the same crash safety as every other write: there is + * no window in which the store is missing rather than empty. Note the + * limitation this does *not* overcome — see `docs/security-model.md`: the + * replaced file's old blocks are not securely erased, because no application + * running on a journalling filesystem can promise that. + */ +export async function clearMemoryScope( + access: MemoryStoreAccess, + scope: MemoryScope, + now: string, +): Promise { + const store = await access.read(scope); + const affected = activeMemories(store.records, now).length; + await commit(access, store, [], now); + return { scope, affected, rejected: 0 }; +} + +/** + * Builds the document an export writes. + * + * Carries the records and the time, and **nothing about where they came + * from**: no path, no project name, no project key, no machine name, no user + * name. An export is the one artefact of this application designed to leave + * the machine, so it says what the notes are and nothing about the + * environment that produced them. + */ +export async function buildMemoryExport( + access: MemoryStoreAccess, + scope: MemoryScope, + now: string, +): Promise { + const store = await access.read(scope); + const parsed = memoryExportSchema.safeParse({ + kind: MEMORY_EXPORT_KIND, + schemaVersion: MEMORY_SCHEMA_VERSION, + exportedAt: now, + scope, + records: orderMemories(activeMemories(store.records, now)), + }); + if (!parsed.success) throw new MemoryError('MEMORY_EXPORT_FAILED'); + return parsed.data; +} + +/** + * Adds validated records from an export document into `scope`. + * + * Two different granularities of refusal, deliberately: + * + * - The **document** is all-or-nothing. If it is not a `MEMORY_EXPORT_KIND` + * document of this schema version, with a scope matching the target and a + * valid record list, nothing at all is imported. A file that is merely + * valid JSON is refused as "not a memory export" rather than being probed + * field by field for something that happens to fit. + * - Individual **records** may be refused while the rest import, and the + * count is reported. A record is refused if its content looks like a + * credential, or if the scope already holds the same note. Both are + * counted in `rejected`, which means "not imported" rather than naming a + * reason per record — a per-record reason would be one more way for + * imported content to shape what is displayed. + * + * Every imported record is re-stamped: a **fresh id**, so a crafted file + * cannot collide with or address an existing record; `source: 'import'`, so + * the interface can always show that this note came from outside; and + * `updatedAt: now`. `createdAt` is kept from the document, because when a + * note was originally written is the one piece of history worth carrying + * across machines, and it is a validated ISO-8601 string either way. + */ +export async function importMemoryRecords( + access: MemoryStoreAccess, + scope: MemoryScope, + document: unknown, + now: string, + newId: () => string, +): Promise { + const parsedDocument = memoryExportSchema.safeParse(document); + if (!parsedDocument.success) throw new MemoryError('MEMORY_IMPORT_INVALID'); + if (parsedDocument.data.scope !== scope) throw new MemoryError('MEMORY_IMPORT_INVALID'); + + const store = await access.read(scope); + const existing = activeMemories(store.records, now); + const seen = new Set(existing.map((record) => normalizeContent(record.content))); + + const accepted: MemoryRecord[] = []; + let rejected = 0; + + for (const candidate of parsedDocument.data.records) { + if (findLikelySecret(candidate.content) !== null) { + rejected += 1; + continue; + } + + const key = normalizeContent(candidate.content); + if (seen.has(key)) { + rejected += 1; + continue; + } + + const parsed = memoryRecordSchema.safeParse({ + ...candidate, + id: newId(), + scope, + source: 'import', + updatedAt: now, + }); + if (!parsed.success) { + rejected += 1; + continue; + } + + seen.add(key); + accepted.push(parsed.data); + } + + if (existing.length + accepted.length > MEMORY_MAX_RECORDS_PER_SCOPE) { + // Refused outright rather than importing however many happen to fit: a + // partial import that silently dropped the rest would look like success. + throw new MemoryError('MEMORY_LIMIT_REACHED'); + } + + await commit(access, store, [...existing, ...accepted], now); + return { scope, affected: accepted.length, rejected }; +} + +/** The same normalisation `dedupeMemories` uses, applied across documents. */ +function normalizeContent(content: string): string { + return content.toLowerCase().replace(/\s+/g, ' ').trim(); +} diff --git a/src/main/memory-store.ts b/src/main/memory-store.ts new file mode 100644 index 0000000..ec1e7eb --- /dev/null +++ b/src/main/memory-store.ts @@ -0,0 +1,255 @@ +/** + * Memory storage (Phase 2, Milestone 8). + * + * Reads and writes `memory/personal.json` and `memory/projects/.json`, + * and holds the session scope in memory. It mirrors `main/settings.ts`, + * `main/policy.ts` and `main/agent-profiles.ts` deliberately and in detail, + * because a memory file is the same *kind* of thing those are: a + * user-editable document this application must be able to read without + * trusting it. + * + * The properties carried over from those modules: + * + * - **Loading never throws and never merges.** A missing file, an unreadable + * one, an oversized one, malformed JSON, a `__proto__` key anywhere in it, + * a document the schema rejects, or a document declaring a scope other + * than the one that was asked for, all resolve the same way: an empty + * store for that scope. A document either validates in full, as itself, or + * it is discarded in full. There is no partial-trust path. + * - **Writing is atomic and re-validated.** The document is checked against + * the schema immediately before serialising, written to a uniquely named + * temporary file in the same directory, flushed, and moved into place with + * a single rename — so a crash or a concurrent write leaves either the + * previous complete document or the new one, never a partial write. + * + * And three of its own, all of which exist because this file holds the user's + * private notes rather than their configuration: + * + * - **The session scope is never written anywhere.** It lives in a closure + * created per `registerIpcHandlers` call, exactly like the approved-project + * session, and closing the application ends it. There is no file to find + * afterwards and no "session" branch in any function that touches disk. + * - **A project's notes are addressed by a hash of its canonical root path, + * never by the path itself.** A path carries a user name, often a client + * or employer name, and sometimes the existence of a project that is + * itself confidential. A directory listing of `%APPDATA%` should not + * disclose any of that, so the file name is a truncated SHA-256 digest. + * - **Loading one project's store cannot reach another's.** The file name is + * derived, never supplied: no caller passes a path, a key or a project + * name into this module, so there is no parameter through which one + * project's session could name another project's file. + * + * Every function takes a plain file path rather than a `UserDataPaths`, for + * the reason `loadSettings` does: a test points at a temporary directory and + * configures nothing else. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { + FORBIDDEN_OBJECT_KEYS, + MEMORY_PROJECT_KEY_LENGTH, + MEMORY_STORE_MAX_BYTES, +} from '../shared/constants'; +import type { MemoryScope } from '../shared/constants'; +import { createEmptyMemoryStore, memoryStoreSchema } from '../shared/schemas/memory.schema'; +import type { MemoryStore } from '../shared/schemas/memory.schema'; + +/** + * Mirrors `main/policy.ts`'s and `main/agent-profiles.ts`'s own copies + * exactly, including the reasoning: `JSON.parse` does not fall for a literal + * `__proto__` key, but nothing downstream of this loader is allowed to assume + * that. A fourth independent copy rather than a shared import, so this module + * gains no runtime dependency on an already-reviewed module from an earlier + * milestone for one small, self-contained, pure check. + */ +const MAX_MEMORY_JSON_DEPTH = 64; + +function containsForbiddenKey(value: unknown, depth = 0): boolean { + if (depth > MAX_MEMORY_JSON_DEPTH) return true; + if (value === null || typeof value !== 'object') return false; + + if (Array.isArray(value)) { + return value.some((element) => containsForbiddenKey(element, depth + 1)); + } + + const record = value as Record; + for (const key of Object.keys(record)) { + if (FORBIDDEN_OBJECT_KEYS.includes(key)) return true; + if (containsForbiddenKey(record[key], depth + 1)) return true; + } + return false; +} + +/** + * A destination-file rename can transiently fail on Windows when something + * else briefly holds the destination open. The same sharing-violation retry + * `main/settings.ts` performs, and for the same reason: each attempt is one + * whole-file rename, so retrying risks a delayed write, never a partial one. + */ +const RENAME_MAX_ATTEMPTS = 10; +const RENAME_RETRY_DELAY_MS = 20; + +function isTransientRenameError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('code' in error)) return false; + const { code } = error; + return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function renameWithRetry(from: string, to: string): Promise { + for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt += 1) { + try { + await rename(from, to); + return; + } catch (error) { + if (attempt === RENAME_MAX_ATTEMPTS || !isTransientRenameError(error)) throw error; + await delay(RENAME_RETRY_DELAY_MS * attempt); + } + } +} + +/** + * The file-name key for one approved project. + * + * A truncated SHA-256 of the project's **canonical** root path. Canonical + * matters: two spellings of the same directory — a short (8.3) ancestor, a + * different case, a trailing separator — must resolve to the same notes, and + * the caller is responsible for passing the path `adoptProjectDirectory` + * already canonicalised. The path itself is never stored, never logged and + * never part of a record. + * + * Lowercased before hashing because Windows paths are case-insensitive: the + * same project opened as `C:\Work\App` and `C:\work\app` is the same project, + * and two stores for it would silently split someone's notes in half. + */ +export function memoryProjectKey(canonicalRootPath: string): string { + return createHash('sha256') + .update(canonicalRootPath.toLowerCase(), 'utf8') + .digest('hex') + .slice(0, MEMORY_PROJECT_KEY_LENGTH); +} + +/** Where one project's notes live. Derived, never supplied by a caller. */ +export function resolveProjectMemoryFile( + memoryProjectsDir: string, + canonicalRootPath: string, +): string { + return join(memoryProjectsDir, `${memoryProjectKey(canonicalRootPath)}.json`); +} + +/** + * Loads `storeFile`, failing safe on every problem. + * + * Never throws, and never creates a file. A first launch is indistinguishable, + * by design, from a corrupted one: both return an empty store, which loses + * nothing that was readable and is the only answer that cannot be wrong in + * the permissive direction. + * + * The size check happens before the read, not after: a file that grew without + * bound should not be pulled into memory to discover that it is too large. + * + * `expectedScope` is checked against the document's own declared scope, so a + * project file that claims to hold personal records — or a personal file + * moved into the projects directory — is refused rather than being silently + * re-labelled as whatever the caller asked for. + */ +export async function loadMemoryStore( + storeFile: string, + expectedScope: MemoryScope, +): Promise { + try { + const stats = await stat(storeFile); + if (stats.size > MEMORY_STORE_MAX_BYTES) return createEmptyMemoryStore(expectedScope); + } catch { + // Missing file (first launch) and any other stat failure fail the same + // way as an unreadable one. Neither the error nor the path is surfaced. + return createEmptyMemoryStore(expectedScope); + } + + let raw: string; + try { + raw = await readFile(storeFile, 'utf8'); + } catch { + return createEmptyMemoryStore(expectedScope); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return createEmptyMemoryStore(expectedScope); + } + + if (containsForbiddenKey(parsed)) return createEmptyMemoryStore(expectedScope); + + const result = memoryStoreSchema.safeParse(parsed); + if (!result.success) return createEmptyMemoryStore(expectedScope); + if (result.data.scope !== expectedScope) return createEmptyMemoryStore(expectedScope); + + return result.data; +} + +/** + * Writes `store` to `storeFile` atomically. + * + * Re-validated against the schema immediately before serialising, so a caller + * cannot persist a document that only *claims* the type at compile time — + * which includes the cross-record rule that every record belongs to the + * store's own scope, the one rule that keeps a project's notes from ending up + * in another scope's file. + */ +export async function writeMemoryStore(storeFile: string, store: MemoryStore): Promise { + const validated = memoryStoreSchema.parse(store); + const payload = JSON.stringify(validated, null, 2); + + const dir = dirname(storeFile); + await mkdir(dir, { recursive: true }); + + const tempFile = join(dir, `${basename(storeFile)}.${randomUUID()}.tmp`); + + try { + const handle = await open(tempFile, 'w'); + try { + await handle.writeFile(payload, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + + await renameWithRetry(tempFile, storeFile); + } catch (error) { + await rm(tempFile, { force: true }).catch(() => undefined); + throw error; + } +} + +/** + * The session scope: one store, in memory, for the lifetime of one run. + * + * One per `registerIpcHandlers` call — never a module-level singleton, so one + * test's session memory can never leak into another's, exactly as + * `createWorkspaceSession` is built. + */ +export interface SessionMemoryStore { + read(): MemoryStore; + write(store: MemoryStore): void; +} + +export function createSessionMemoryStore(): SessionMemoryStore { + let store: MemoryStore = createEmptyMemoryStore('session'); + return { + read: () => store, + write: (next) => { + // Validated on the way in for the same reason the file writer validates + // on the way out: nothing persisted or served should rest on a caller's + // compile-time claim about its type. + store = memoryStoreSchema.parse(next); + }, + }; +} diff --git a/src/main/memory-transfer.ts b/src/main/memory-transfer.ts new file mode 100644 index 0000000..8bc5234 --- /dev/null +++ b/src/main/memory-transfer.ts @@ -0,0 +1,113 @@ +/** + * Reading and writing memory files outside the application (P2, M8). + * + * The only module in this milestone that touches a path the application does + * not own. Both directions are deliberately narrow: + * + * - **Neither function chooses a path.** The path always comes from a native + * dialog the main process owns (`main/memory-picker.ts`), so the renderer + * cannot name a file to read or a file to overwrite. A dialog result is + * still *input*, but it is input the user clicked on, which is the same + * consent model `workspace:select` already uses. + * - **An import is untrusted content.** It is size-checked before it is + * read, parsed as JSON with no reviver, screened for a prototype-polluting + * key, and returned as `unknown` — this module never claims it is a memory + * document. `main/memory-service.ts` decides that, against the schema. + * - **An export is written atomically.** A half-written export is worse than + * a failed one: someone who believes their notes are backed up will delete + * the originals. Temporary file, flush, single rename. + * - **No error escapes.** Every failure becomes a `MemoryError` carrying a + * code from the closed vocabulary. A filesystem error message would name + * the path, and a path is user data. + */ + +import { randomUUID } from 'node:crypto'; +import { open, readFile, rename, rm, stat } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { FORBIDDEN_OBJECT_KEYS, MEMORY_IMPORT_MAX_BYTES } from '../shared/constants'; +import { MemoryError } from '../shared/memory/errors'; +import type { MemoryExport } from '../shared/schemas/memory.schema'; + +const MAX_IMPORT_JSON_DEPTH = 64; + +/** The same guard `main/memory-store.ts` applies, on a far less trusted file. */ +function containsForbiddenKey(value: unknown, depth = 0): boolean { + if (depth > MAX_IMPORT_JSON_DEPTH) return true; + if (value === null || typeof value !== 'object') return false; + + if (Array.isArray(value)) { + return value.some((element) => containsForbiddenKey(element, depth + 1)); + } + + const record = value as Record; + for (const key of Object.keys(record)) { + if (FORBIDDEN_OBJECT_KEYS.includes(key)) return true; + if (containsForbiddenKey(record[key], depth + 1)) return true; + } + return false; +} + +/** + * Reads and parses the file the user chose, without interpreting it. + * + * The size check happens before the read, exactly as it does for this + * application's own stores: a file that is too large should not be pulled + * into the privileged process's memory in order to discover that. + */ +export async function readMemoryImportFile(sourcePath: string): Promise { + let size: number; + try { + size = (await stat(sourcePath)).size; + } catch { + throw new MemoryError('MEMORY_IMPORT_INVALID'); + } + if (size > MEMORY_IMPORT_MAX_BYTES) throw new MemoryError('MEMORY_IMPORT_TOO_LARGE'); + + let raw: string; + try { + raw = await readFile(sourcePath, 'utf8'); + } catch { + throw new MemoryError('MEMORY_IMPORT_INVALID'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new MemoryError('MEMORY_IMPORT_INVALID'); + } + + if (containsForbiddenKey(parsed)) throw new MemoryError('MEMORY_IMPORT_INVALID'); + return parsed; +} + +/** + * Writes an export document to the file the user chose. + * + * The temporary file is created beside the destination so the rename stays on + * one volume — a cross-volume rename is a copy, which would defeat the point. + * It is removed on every failure path, so a failed export leaves nothing + * behind in someone's Documents folder. + */ +export async function writeMemoryExportFile( + targetPath: string, + document: MemoryExport, +): Promise { + const payload = JSON.stringify(document, null, 2); + const tempFile = join(dirname(targetPath), `${basename(targetPath)}.${randomUUID()}.tmp`); + + try { + const handle = await open(tempFile, 'w'); + try { + await handle.writeFile(payload, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(tempFile, targetPath); + } catch { + await rm(tempFile, { force: true }).catch(() => undefined); + throw new MemoryError('MEMORY_EXPORT_FAILED'); + } +} diff --git a/src/main/paths.ts b/src/main/paths.ts index be54561..7e2146a 100644 --- a/src/main/paths.ts +++ b/src/main/paths.ts @@ -28,6 +28,10 @@ export interface UserDataPaths { readonly auditLogDir: string; readonly emergencyStateFile: string; readonly memoryDir: string; + /** Personal memory (Phase 2, Milestone 8). Never holds a credential. */ + readonly memoryPersonalFile: string; + /** One file per approved project, named from a hash of its canonical root. */ + readonly memoryProjectsDir: string; /** Pre-change backups (Phase 2, Milestone 6). Never inside a user project. */ readonly backupsDir: string; /** The empty directory `git` is pointed at instead of a repository's hooks. */ @@ -53,6 +57,8 @@ export function resolveUserDataPaths(appDataDir: string): UserDataPaths { auditLogDir: join(userDataDir, USER_DATA_PATHS.auditLogDir), emergencyStateFile: join(userDataDir, USER_DATA_PATHS.emergencyStateFile), memoryDir: join(userDataDir, USER_DATA_PATHS.memoryDir), + memoryPersonalFile: join(userDataDir, USER_DATA_PATHS.memoryPersonalFile), + memoryProjectsDir: join(userDataDir, USER_DATA_PATHS.memoryProjectsDir), backupsDir: join(userDataDir, USER_DATA_PATHS.backupsDir), gitHooksDir: join(userDataDir, USER_DATA_PATHS.gitHooksDir), agentProfilesFile: join(userDataDir, USER_DATA_PATHS.agentProfilesFile), diff --git a/src/preload/index.ts b/src/preload/index.ts index 98b50c1..f70a3fd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,32 @@ import { IPC_AGENT_SELECT_CHANNEL, IPC_AGENT_SET_ENABLED_CHANNEL, IPC_AGENT_UPDATE_CHANNEL, + IPC_MEMORY_ADD_CHANNEL, + IPC_MEMORY_CLEAR_CHANNEL, + IPC_MEMORY_DELETE_CHANNEL, + IPC_MEMORY_EXPORT_CHANNEL, + IPC_MEMORY_IMPORT_CHANNEL, + IPC_MEMORY_LIST_CHANNEL, + IPC_MEMORY_RETRIEVE_CHANNEL, + IPC_MEMORY_SEARCH_CHANNEL, + IPC_MEMORY_SET_PINNED_CHANNEL, + IPC_MEMORY_UPDATE_CHANNEL, + memoryAddResponseSchema, + memoryClearResponseSchema, + memoryDeleteResponseSchema, + memoryExportResponseSchema, + memoryImportResponseSchema, + memoryListResponseSchema, + memoryRetrieveResponseSchema, + memorySearchResponseSchema, + memorySetPinnedResponseSchema, + memoryUpdateResponseSchema, + type MemoryMutationResponse, + type MemoryQueryResponse, + type MemoryRecordInput, + type MemoryRecordResponse, + type MemoryRetrieveResponse, + type MemoryScopeValue, type AgentProfileInput, type AgentRegistryResponse, type AgentRunResponse, @@ -385,6 +411,77 @@ const bridge = { agentCancelResponseSchema.parse(result); }, }, + /** + * Local memory (Phase 2, Milestone 8). + * + * Ten functions, ten fixed channels, and three properties worth checking + * first: + * + * - **Nothing here can name a file.** `exportScope` and `importScope` take + * a scope and nothing else; the file is chosen by the user in a native + * dialog the main process owns. There is no path parameter anywhere in + * this object. + * - **Nothing here can label a record's provenance.** The add and update + * payloads have no `source` field — the main process stamps `user` or + * `import`, so a compromised renderer cannot pass imported content off + * as something the person typed. + * - **Nothing here can move a record between scopes.** An update addresses + * a record inside the scope its own record names, and a stored record + * found elsewhere is refused rather than relocated. + */ + memory: { + list: async (scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_LIST_CHANNEL, { scope }); + return memoryListResponseSchema.parse(result); + }, + search: async (scope: MemoryScopeValue, query: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_SEARCH_CHANNEL, { + scope, + query, + }); + return memorySearchResponseSchema.parse(result); + }, + retrieve: async (objective: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_RETRIEVE_CHANNEL, { objective }); + return memoryRetrieveResponseSchema.parse(result); + }, + add: async (record: MemoryRecordInput): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_ADD_CHANNEL, { record }); + return memoryAddResponseSchema.parse(result); + }, + update: async (id: string, record: MemoryRecordInput): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_UPDATE_CHANNEL, { id, record }); + return memoryUpdateResponseSchema.parse(result); + }, + setPinned: async ( + id: string, + scope: MemoryScopeValue, + pinned: boolean, + ): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_SET_PINNED_CHANNEL, { + id, + scope, + pinned, + }); + return memorySetPinnedResponseSchema.parse(result); + }, + remove: async (id: string, scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_DELETE_CHANNEL, { id, scope }); + return memoryDeleteResponseSchema.parse(result); + }, + clear: async (scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_CLEAR_CHANNEL, { scope }); + return memoryClearResponseSchema.parse(result); + }, + exportScope: async (scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_EXPORT_CHANNEL, { scope }); + return memoryExportResponseSchema.parse(result); + }, + importScope: async (scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_IMPORT_CHANNEL, { scope }); + return memoryImportResponseSchema.parse(result); + }, + }, git: { status: async (): Promise => { const result: unknown = await ipcRenderer.invoke(IPC_GIT_STATUS_CHANNEL); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5b1020d..c04eeff 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'; import { Agents } from './agent/Agents'; import { Chat } from './chat/Chat'; +import { Memory } from './memory/Memory'; import { Onboarding } from './Onboarding'; import { Workspace } from './workspace/Workspace'; import { createDefaultSettings } from '../shared/schemas'; import type { Settings } from '../shared/schemas'; type Phase = 'loading' | 'onboarding' | 'ready'; -/** Which surface the shell is showing (Phase 2, Milestones 5 and 7). */ -type View = 'chat' | 'workspace' | 'agents'; +/** Which surface the shell is showing (Phase 2, Milestones 5, 7 and 8). */ +type View = 'chat' | 'workspace' | 'agents' | 'memory'; /** * Used only when `settings.get` itself did not succeed (denied, or the @@ -115,6 +116,15 @@ export function App() { > Agents + {view === 'chat' && } @@ -122,6 +132,7 @@ export function App() { )} {view === 'agents' && } + {view === 'memory' && } ); } diff --git a/src/renderer/local-agent-bridge.d.ts b/src/renderer/local-agent-bridge.d.ts index 6da3e7d..8ef19a5 100644 --- a/src/renderer/local-agent-bridge.d.ts +++ b/src/renderer/local-agent-bridge.d.ts @@ -22,6 +22,12 @@ import type { GitDiffResponse, GitStatusResponse, HealthCheckResponse, + MemoryMutationResponse, + MemoryQueryResponse, + MemoryRecordInput, + MemoryRecordResponse, + MemoryRetrieveResponse, + MemoryScopeValue, SecretsActionResponse, SettingsActionResponse, SettingsUpdateInput, @@ -110,6 +116,31 @@ declare global { readonly run: (runId: string, objective: string) => Promise; readonly cancel: (runId: string) => Promise; }; + /** + * Local memory (Phase 2, Milestone 8). Note that `exportScope` and + * `importScope` take a scope and nothing else: the file is chosen by + * the user in a native dialog the main process owns, so there is no + * path parameter here to name a file to read or to overwrite. Note also + * that no write carries a `source`: whether a record was typed or + * imported is stamped in the main process, never claimed from here. + */ + readonly memory: { + readonly list: (scope: MemoryScopeValue) => Promise; + readonly search: (scope: MemoryScopeValue, query: string) => Promise; + /** The small relevant set — never the whole store. */ + readonly retrieve: (objective: string) => Promise; + readonly add: (record: MemoryRecordInput) => Promise; + readonly update: (id: string, record: MemoryRecordInput) => Promise; + readonly setPinned: ( + id: string, + scope: MemoryScopeValue, + pinned: boolean, + ) => Promise; + readonly remove: (id: string, scope: MemoryScopeValue) => Promise; + readonly clear: (scope: MemoryScopeValue) => Promise; + readonly exportScope: (scope: MemoryScopeValue) => Promise; + readonly importScope: (scope: MemoryScopeValue) => Promise; + }; /** * Git (Phase 2, Milestone 6). `checkpoint` takes no argument: nothing * here can name a ref, a branch, a remote or a commit message, and diff --git a/src/renderer/memory/Memory.tsx b/src/renderer/memory/Memory.tsx new file mode 100644 index 0000000..c07a009 --- /dev/null +++ b/src/renderer/memory/Memory.tsx @@ -0,0 +1,458 @@ +/** + * The Memory Centre (Phase 2, Milestone 8). + * + * A fourth sibling next to Chat, Workspace and Agents, not a redesign: it + * reuses the existing panel, badge, hint, field and error classes rather than + * introducing a visual language of its own. + * + * What it is for, in the milestone's own terms: listing and searching + * memories, showing each one's scope and source, pinning, editing, deleting, + * clearing a scope, exporting and importing — and **stating plainly that this + * is local-only**. That last point is why the privacy line is part of the + * header rather than buried in a settings page: someone looking at a list of + * notes about themselves should be able to see, without navigating anywhere, + * that the notes are on this machine and that nothing here uploads them. + * + * Nothing here is a security control. The form validates before sending + * purely so a person sees a mistake immediately; the main process + * re-validates everything it receives, runs its own credential screen, and is + * the only thing that decides what is stored. + */ + +import { useState } from 'react'; + +import { useMemory } from './useMemory'; +import { + MEMORY_CATEGORIES, + MEMORY_CONFIDENCE_DEFAULT, + MEMORY_CONFIDENCE_MAX, + MEMORY_CONFIDENCE_MIN, + MEMORY_CONTENT_MAX_LENGTH, + MEMORY_IMPORTANCE_DEFAULT, + MEMORY_IMPORTANCE_MAX, + MEMORY_IMPORTANCE_MIN, + MEMORY_SCOPES, +} from '../../shared/constants'; +import { memoryRecordInputSchema } from '../../shared/schemas'; +import type { MemoryRecord, MemoryRecordInput, MemoryScopeValue } from '../../shared/schemas'; + +/** One sentence per scope, describing where its records actually live. */ +const SCOPE_DESCRIPTIONS: Readonly> = { + session: + 'Held in memory for this run of Local Agent only. Never written to disk — closing the application ends it.', + project: + 'Stored in a file belonging to the project you approved in the Workspace. Another project never opens it.', + personal: + 'Stored on this machine, in Local Agent’s own data folder. The only scope that outlives both the session and the project.', +}; + +const SCOPE_LABELS: Readonly> = { + session: 'Session', + project: 'Project', + personal: 'Personal', +}; + +/** A blank record for the create form, in the scope currently shown. */ +function emptyDraft(scope: MemoryScopeValue): MemoryRecordInput { + return { + scope, + category: 'user-preference', + content: '', + importance: MEMORY_IMPORTANCE_DEFAULT, + confidence: MEMORY_CONFIDENCE_DEFAULT, + expiresAt: null, + pinned: false, + }; +} + +/** Strips the derived fields so an existing record can seed the form. */ +function draftFrom(record: MemoryRecord): MemoryRecordInput { + const { + id: _id, + source: _source, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...input + } = record; + return input; +} + +/** The date part of an ISO timestamp, for display and for a `date` input. */ +function isoDate(value: string): string { + return value.slice(0, 10); +} + +function MemoryCard(props: { + readonly record: MemoryRecord; + readonly canAct: boolean; + readonly onEdit: () => void; + readonly onDelete: () => void; + readonly onTogglePinned: () => void; +}) { + const { record, canAct } = props; + + return ( +
+
+ {SCOPE_LABELS[record.scope]} + {record.category} + {/* Source is always shown: whether a note was typed here or came + from an imported file is exactly the kind of provenance a reader + needs in order to weigh it. */} + + {record.source === 'user' ? 'Typed here' : 'Imported'} + + {record.pinned && Pinned} +
+ +

{record.content}

+ +

+ Importance {record.importance}/{MEMORY_IMPORTANCE_MAX} · confidence {record.confidence}% · + updated {isoDate(record.updatedAt)} + {record.expiresAt !== null && ` · expires ${isoDate(record.expiresAt)}`} +

+ +
+ + + +
+
+ ); +} + +export function Memory() { + const memory = useMemory(); + const { state, canAct } = memory; + + const [queryText, setQueryText] = useState(''); + const [draft, setDraft] = useState(null); + const [editingId, setEditingId] = useState(null); + + // Validated here only so a mistake is visible immediately. The main process + // re-validates everything it receives and is what actually decides. + const validation = draft === null ? null : memoryRecordInputSchema.safeParse(draft); + const draftIssues = + validation === null || validation.success + ? [] + : validation.error.issues.map( + (issue) => `${issue.path.join('.') || 'memory'}: ${issue.message}`, + ); + + function updateDraft(changes: Partial): void { + setDraft((current) => (current === null ? current : { ...current, ...changes })); + } + + function closeDraft(): void { + setDraft(null); + setEditingId(null); + } + + async function submitDraft(): Promise { + if (draft === null || validation === null || !validation.success) return; + if (editingId === null) await memory.addMemory(validation.data); + else await memory.updateMemory(editingId, validation.data); + closeDraft(); + } + + return ( +
+
+
+

Memory

+

+ Short notes you write about how you want to be worked with. Nothing here is written by a + model, and nothing here grants a permission. +

+ {/* The privacy status the milestone requires, stated where it + cannot be missed rather than in a settings page. */} +

+ Local only. These notes stay on this machine. Local Agent never uploads + them, never syncs them, and never sends the whole store anywhere — only a handful of + relevant notes are ever retrieved at a time. +

+
+
+

+ {state.busy === null ? 'Idle' : `Working: ${state.busy}`} +

+ {state.activity !== null &&

{state.activity}

} +
+
+ + {state.error !== null && ( +
+

{state.error.message}

+ + {state.error.retryable && ( + + )} + + +
+ )} + + + +

{SCOPE_DESCRIPTIONS[state.scope]}

+ +
+
+
+

+ {SCOPE_LABELS[state.scope]} memories ({state.total}) +

+ +
+ +
{ + event.preventDefault(); + void memory.search(queryText); + }} + > + + { + setQueryText(event.target.value); + }} + /> + + {state.query.length > 0 && ( + + )} +
+ + {!state.initialized &&

Loading memories…

} + + {state.initialized && state.records.length === 0 && ( +

+ {state.query.length > 0 + ? 'No memory matches that search.' + : 'Nothing is remembered in this scope yet.'} +

+ )} + + {state.records.map((record) => ( + { + setDraft(draftFrom(record)); + setEditingId(record.id); + }} + onDelete={() => void memory.deleteMemory(record.id)} + onTogglePinned={() => void memory.setPinned(record.id, !record.pinned)} + /> + ))} + + {state.truncated && ( +

+ Showing the first {state.records.length} of {state.total}. Narrow the list with a + search. +

+ )} +
+ +
+

This scope

+

+ Clearing, exporting and importing each ask for confirmation in a dialog the main process + owns. Export and import choose the file in a native dialog — Local Agent never picks a + file for you. +

+
+ + + +
+

+ An imported file is untrusted: every record is validated, given a fresh identifier, + labelled as imported, and anything that looks like a key, token or password is refused. +

+ + {draft !== null && ( +
+

{editingId === null ? 'New memory' : 'Edit memory'}

+ +
+ +