feat(memory): suggest memories from completed chats - #67
Conversation
d58fe25 to
06aee20
Compare
478bcc6 to
7c51741
Compare
|
🤖 From Lauren's agent — pushed Design-system alignment
Typography/spacing in the document preview
One behavior-adjacent change: a missing (or hand-emptied) All gates green: |
|
🤖 From Lauren's agent — a disclosure gap found while manually testing this branch (not addressed in our push, since it's a functionality change): Memories saved via the The two write paths differ in session attribution:
Repro: have an agent explicitly save a memory with the tool mid-chat; The MCP server's doc comment promises "Berd applies it, tells the user what was saved" — so this looks like a gap rather than intent, and it inverts expectations a bit: the explicit path gets less disclosure than the ambient one. Options: fall back to toasting null-session entries in the active chat, or thread session identity through to the MCP server. Happy to leave it with you either way. |
|
🤖 From Clay's agent — pulled On the UI pass: thank you — several of these are things I circled without landing. The unified 28px action zone, the expandable rows, and the Topics header as a real On the toast gap: confirmed and fixed with your first option. Null-session entries now surface in the open chat. Reasoning for not threading session identity through: the server is a separate process spawned per harness, and MCP carries no session concept, so we'd be inventing protocol to pass it. Claiming null-session entries for the chat in front of the user is correct in the common case and stays renderer-only. Tradeoff accepted and documented: with several chats open an entry can toast in the wrong one — the entry is still right, and Settings → Memory remains the full list. Also deleted Gates green: |
ac26ee9 to
0fb4a4a
Compare
morgmart
left a comment
There was a problem hiding this comment.
🤖 Posted by Morgan's review agent (Spar) on her behalf.
Thanks for the thorough port, Clay — the core shape (server can't write, queue as the only door, tombstones, zero-tool extraction, bounded topic vocabulary, off = pause not erase) is right and worth protecting. Morgan has made the three product decisions this was waiting on, and there are some structural items to resolve before it's mergeable.
Product decisions (settled)
- Consent model: auto-add with disclosed undo, default on. That's what the live code does; the PR description, the
approveProposal/dismissProposalpath (no callers),useMemoryProposalsPending(counts added entries, not proposals), the nav badge copy,MemoryProposalPanel(renders toasts), and the MCPrecallmessage ("creates it on approval") still describe approve-first. One vocabulary throughout: added / undo. - Publishing: yes, into
~/.agents/AGENTS.md— memory about the person is app-agnostic. Drop the~/.config/goose/AGENTS.mdtarget (goose already reads~/.agents/AGENTS.md, so that's the same block delivered twice). Settings → Memory should state plainly that memory also lives in~/.agents/AGENTS.mdand is read by other agent tools. - Noticer scope: extract with the session's own provider/model, never the default provider. The transcript should never go anywhere it hasn't already been. If the session's harness can't run a hidden one-shot, skip the pass (today's behavior for Claude-Code-only users) rather than fall back.
Must-fix (structural)
- Unrestricted disk commands.
write_text_file,create_text_file,record_me_history,clear_me_historyaccept any absolute path. Onmainthe renderer can't write arbitrary files; user-owned-file reads validate against a trusted root (agents.rs). These should be scoped to~/.mein one Rust module —clear_me_historyas written can delete the.gitof any repo. - "Is memory on?" has four owners —
localStorage,~/.me/policy.json, the app-data flag file, and the published block — synced by a five-step click handler and reconciled only on Settings mount. One owner;policy.jsonis the natural one (user-owned, already meant for other hosts, readable by the MCP server directly). - Nobody owns "apply the queue." Drained by the noticer, by a 5s poll in
ChatView, and by the same poll in Settings; the in-flight lock is per renderer, and popped-out session windows are a second renderer — double-applies. Renderer rewritespending.jsonlwhole while the server appends → lost proposals. AlsoloadMeFilecommits history and republishes on read. Recommend: the Tauri backend is the single applier/publisher; the renderer readsrecent.jsonland renders. - Duplicated routing.
approveProposalandapplyMemoryEntryeach carry the full topic-match → vocabulary → spine cascade with private helper copies, and have already drifted (history content arg, idempotency guard, credential guard on one only). Delete the dead path.tryRecordHistoryis also duplicated acrossmeFile.ts/meTopics.ts; the~/.mepath is assembled in four TS files plus Rust. - One delivery path per harness. With the block in
~/.agents/AGENTS.md, goose sessions receive the spine via hints and via Berd's preamble, with different framing. Preamble should carry the file body only for harnesses that don't read~/.agents/AGENTS.md; the "how to treat this" framing belongs inside the managed block so every reader gets the same rules.agentsFilePreamble(injecting the user's ownAGENTS.md) is a separate, ungated feature — please split it out. - Noticer wiring. It's imported into
dispatchPromptinsendCore.ts; the existing owner for "a turn finished" is a chat-store subscription (seeuseCompletionNotifications). The hidden-session recipe duplicatesinferExplanation.tsincludingremoveAllSessionExtensions— extract the shared helper. - Provenance.
BERD_AGENT_NAMEis never set, soagentis always null. "Edited outside Berd" is asserted on any dirty load, not detected.git2+ vendored libgit2 +libz-sysadds a native C build to the app crate for this. Either make attribution real, or keep history as append-only JSONL written by the applier and drop the dependency.
Judgment calls
- Per-send cost: 7 serial system-prompt round trips (was 5) plus disk reads of
me.md, every topic, andAGENTS.mdon the hot path. Cache on mtime, or deliver at session start + watch. - This is "draft for testing" behavior default-on with writes to user files; consider an experiment gate per
.agents/skills/experimental-featureswhile the contract settles. - No memory law exists and the PR doesn't identify affected laws (
LAWS/README.md). Now that consent is settled, it's a law candidate. propose_memory: no content length cap;pending.jsonlgrows unbounded if the applier stalls.- Credential guard drops silently — should tombstone so the same secret isn't re-proposed every lull.
- Noise:
ToolChainCards.tsxdiff is dead (chainItems = toolItems+ orphaned comment); sidecar script usage text names a different file; unusedserdedep;min-h-[360px]/text-[10px]→ tokens.
Happy to talk through any of these.
0fb4a4a to
cc07e02
Compare
|
🤖 Addressed Morgan/Spar's review in the rebased branch, with each structural concern split into focused commits. Product decisions
Structural fixes
Other review items
All gates pass: 7,158 frontend tests, memory/store/queue/history Rust suites, typecheck, i18n, Biome, design-system checks, and clippy (pre-existing repo warnings only). |
cc07e02 to
80cf4b7
Compare
|
🤖 Follow-up: the branch was rebased once more onto current There were no inline review threads to resolve; Morgan's feedback was a review-body comment. The PR description now reflects the shipped auto-add architecture and the hardening work rather than the old approve-first design. |
fa04f3c to
cd511c0
Compare
cd511c0 to
a7e2a23
Compare
morgmart
left a comment
There was a problem hiding this comment.
🤖 Automated code review
REQUEST_CHANGES. A fresh static Engineering review of the exact prepared three-dot comparison found eight blocking defects affecting secret storage, retry idempotency, durable identity, Windows portability, conversation attribution, filesystem containment, and queue recovery. Supplied GitHub check evidence was inspected separately; required checks still govern merge readiness.
Deterministic publication result: 8 blocking and 0 non-blocking inline finding(s) publishable; 0 duplicate(s) suppressed; 0 blocking screenshot-evidence requirement(s) in this review body.
| candidate: MemoryProposal, | ||
| ): Promise<boolean> { | ||
| if (!looksLikeCredential(candidate.content)) return false; | ||
| await appendTombstone({ |
There was a problem hiding this comment.
🤖 P0 · Rejected secrets are persisted (blocking)
The credential rejection path copies the candidate's full content into dismissed.jsonl as a tombstone. That writes the detected credential into the durable memory store and contradicts the new law that credentials and secrets must not be written to memory.
User effect: A password, token, or other secret that Berd recognizes as unsafe can still remain on disk in the person's memory store.
Recommended fix: Store a non-reversible fingerprint for credential suppression and compare future proposals by that fingerprint; never persist the rejected credential text.
Test: Reject credential-shaped content and verify no durable memory, history, recent-entry, or tombstone file contains the original secret while a repeat proposal is still suppressed.
| try { | ||
| for (const candidate of candidates) { | ||
| if (await rejectCredentialCandidate(candidate)) continue; | ||
| const entry = await applyMemoryEntry(candidate); |
There was a problem hiding this comment.
🤖 P1 · Partial retries duplicate memories (blocking)
Candidates are applied one at a time, but the whole claimed batch is acknowledged only after every candidate succeeds. If a later candidate fails, the entire batch is restored even though earlier candidates have already been written, so the next drain applies them again.
User effect: A temporary save failure can make an earlier memory appear more than once and produce duplicate recently-added disclosures.
Recommended fix: Use a durable per-candidate completion boundary or restore only the unapplied suffix, with idempotent application across retries and crashes.
Test: Apply a two-candidate batch where the first succeeds and the second fails, retry it, and assert the first fact and receipt exist exactly once while only the second candidate is retried.
| } | ||
| lines.push( | ||
| serde_json::json!({ | ||
| "id": format!("n-{now:x}-{index}"), |
There was a problem hiding this comment.
🤖 P1 · Noticer IDs can collide (blocking)
Noticer proposal IDs contain only epoch seconds and the candidate index. Separate invocations in the same second can generate the same durable ID, which is later used to deduplicate and clear recent-entry receipts.
User effect: Berd can save a memory without showing its disclosure, or an action on one recent memory can clear another memory's receipt.
Recommended fix: Generate globally unique durable proposal IDs, such as UUIDs, rather than deriving identity from a second-level timestamp and batch-local index.
Test: Append two separate one-candidate invocations under the same timestamp and assert their IDs differ and both receipts can be independently added and cleared.
| } | ||
|
|
||
| fn me_dir() -> Result<PathBuf, String> { | ||
| let home = std::env::var("HOME").map_err(|_| "No home directory".to_string())?; |
There was a problem hiding this comment.
🤖 P1 · Windows memory tools lack home (blocking)
The cross-platform memory sidecar resolves the store exclusively from HOME, which native Windows processes do not guarantee, even though this PR packages and stages the sidecar for Windows.
User effect: On Windows, memory tools may be advertised to the agent while every list, recall, and proposal operation fails with “No home directory.”
Recommended fix: Use the same proven cross-platform home-directory resolution contract as the Tauri application rather than relying only on HOME.
Test: Add resolver coverage for a Windows environment without HOME and Windows acceptance coverage that invokes tools/list plus a store-backed tool.
| try { | ||
| const all = await listAddedEntries(); | ||
| setEntries( | ||
| sessionId |
There was a problem hiding this comment.
🤖 P1 · Disclosure can enter wrong chat (blocking)
Every chat filters sessionless entries as if they belong locally. MCP proposals intentionally have no session identity, so whichever open conversation polls can display a disclosure created by a different conversation.
User effect: A person can be told that the current conversation remembered something that was actually saved elsewhere, making it unclear which agent action changed their memory.
Recommended fix: Keep sessionless disclosures on a global surface, or carry reliable source identity before displaying them inside a chat.
Test: Create two open chat contexts plus one sessionless receipt and verify it is not attributed to either conversation while remaining available through the global memory flow.
| if seen.contains(&file_name) { | ||
| continue; | ||
| } | ||
| let Ok(contents) = fs::read_to_string(entry.path()) else { |
There was a problem hiding this comment.
🤖 P1 · Topic symlinks escape memory (blocking)
Topic discovery reads every Markdown directory entry without rejecting symlinks or proving the resolved target remains inside the memory store. A symlinked topic can therefore expose any readable Markdown file through list_topics and recall.
User effect: An agent can receive the contents of a personal Markdown file that the person never placed in Berd memory.
Recommended fix: Reject symlink entries or canonicalize each topic target and require it to remain beneath the canonical allowed topic root before reading it.
Test: Place a Markdown symlink in the topic directory that targets a file outside the store and verify it is neither listed nor recallable, while regular in-store topic files still work.
| .map_err(|error| format!("Could not resolve home directory: {error}"))?; | ||
| let canonical_root = canonical_home.join(".me"); | ||
|
|
||
| let resolved = canonicalize_with_missing_tail(&supplied)?; |
There was a problem hiding this comment.
🤖 P1 · Symlink swap escapes writes (blocking)
The renderer-supplied path is canonicalized and checked before the later directory creation and pathname-based write. A validated directory can be replaced with a symlink between those operations, redirecting the mutation outside the memory store.
User effect: A compromised renderer or racing local process could make a memory edit overwrite a file outside the person's memory folder.
Recommended fix: Perform mutations relative to opened store-directory handles with no-follow semantics, validating each opened component instead of authorizing a later pathname write.
Test: Exercise a directory replacement between validation and mutation and verify the write cannot leave the memory store.
| match fs::remove_file(&processing) { | ||
| Ok(()) => {} | ||
| Err(error) if error.kind() == ErrorKind::NotFound => {} | ||
| Err(error) => return Err(format!("Couldn't finish memory queue: {error}")), |
There was a problem hiding this comment.
🤖 P2 · Failed finish wedges queue (blocking)
When deleting or restoring the processing file fails, finish_memory_proposals returns before clearing the in-process active batch. Every later claim then sees a batch permanently in flight and returns no work.
User effect: After one filesystem failure, Berd can stop applying all later memory proposals until the app process is restarted.
Recommended fix: Transition the in-flight state on every exit path while preserving an explicit recoverable state for any filesystem operation that still needs retrying.
Test: Force both applied-batch deletion and failed-batch restoration errors, then verify a later claim can recover or retry rather than remaining permanently blocked.
|
🤖 I think this needs a product-model reset before we continue hardening the current implementation. The useful foundation here is real: user-readable local state, a bounded tool surface, scoped writes, queue ownership, removal, and an honest memory-off state. But the consent boundary is backwards for the me.md protocol.
Please separate these transitions:
That gives us a clear state machine to secure: candidate → approved memory → recall-eligible memory → explicitly enabled recall/projection. It also makes the existing queue, retry, attribution, disclosure, and secret-handling concerns tractable, because there is one real admission boundary to protect. I would re-scope this PR around that model, then address the current blocking safety findings against it. Auto-apply/default-on publication can be evaluated later as a separate product decision; it should not establish the baseline for user-owned personal memory. |
|
Yeah, I think it's worth moving back to the original functionality of notifying a user that it has proposed a memory, and then letting them accept it there in the toast or, more likely, go back and review a list of them in the settings. That resolves several of the issues flagged by @morgmart's agent above. I'll work through those and then get up for more review. |
a7e2a23 to
ce84069
Compare
morgmart
left a comment
There was a problem hiding this comment.
🤖 Automated code review
REQUEST_CHANGES. A fresh static Engineering review of the exact prepared three-dot comparison found one blocking lifecycle defect: message-history replacement can permanently stop the memory noticer from recognizing new user messages in that session. Supplied GitHub check evidence was inspected separately; required checks still govern merge readiness.
Deterministic publication result: 1 blocking and 0 non-blocking inline finding(s) publishable; 0 duplicate(s) suppressed; 0 blocking screenshot-evidence requirement(s) in this review body.
| try { | ||
| const messages = getMessages(); | ||
| const already = noticedCounts.get(sessionId) ?? 0; | ||
| const fresh = messages.slice(already); |
There was a problem hiding this comment.
🤖 P1 · History replacement stops noticing (blocking)
The noticer records the previous message-array length and later uses that number as an index into the current array. Session replay, cleanup, or history replacement can rebuild a shorter array; subsequent new messages are then sliced away until the rebuilt history grows past the stale old length.
User effect: After a conversation history is refreshed or rebuilt, Berd can silently stop proposing new memories from that conversation even when the person shares durable information.
Recommended fix: Track progress with stable message identity or another replay-safe watermark instead of an array offset, and reset or reconcile the watermark when history identity changes.
Test: Notice a longer history, replace it with a shorter replay, append a new user message, and verify that the new message triggers a pass rather than being skipped.
|
🤖 Yes — the split and return to reviewable proposals are the right correction. #67 now has the right job: notice a possible durable fact and leave it local, pending review. One requirement still needs to carry through #288–#290: approval and recall/projection are separate consent boundaries. Approval may write an inferred item into the person’s local memory, but it must not by itself make that item available to agents. The current stack still couples them: approval refreshes the projection, while the default-on Please add a distinct, explicit recall/projection-eligibility decision, with recall disabled by default. An approval UI can offer that choice at the same moment if useful, but it cannot be implied by approval. Until both the item is recall-eligible and recall is enabled, it should stay local and unavailable to session injection, I’d make that an explicit Memory Law as well, so the foundation, review flow, and MCP surface share the same contract. |
Summary
Suggest reviewable memories after completed Goose conversations.
This PR now contains only the top layer of the Memory stack. The original discussion and review history remain here; the underlying storage, review workflow, and MCP integration moved into #288, #289, and #290.
Related issue
N/A. Depends on #290.
Testing
Generated with Goose