feat: add memory editor to web ui sidebar#334
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a “Memory Editor” panel to the Web UI sidebar and introduces backend API endpoints to read/write memory files so users can view, edit, and save SOUL.md, USER.md, and MEMORY.md.
Changes:
- Added
/api/memory/{file}GET/PUT routes. - Implemented a new API handler for reading/writing memory files on disk.
- Added a new
MemoryEditorSvelte component and embedded it into the sidebar layout.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
pkg/api/routes.go |
Registers new memory GET/PUT API routes. |
pkg/api/memory.go |
Adds handlers to load and persist memory file content. |
packages/ui/src/routes/+layout.svelte |
Adds MemoryEditor to the sidebar and adjusts threads panel sizing. |
packages/ui/src/lib/components/MemoryEditor.svelte |
New UI component for selecting, editing, and saving memory files. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func getMemoryFile(w http.ResponseWriter, r *http.Request) { | ||
| fileName := r.PathValue("file") | ||
| homeDir, err := os.UserHomeDir() | ||
| if err != nil { | ||
| http.Error(w, "Failed to get user home directory", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| filePath := filepath.Join(homeDir, memoryDir, fileName) | ||
| content, err := ioutil.ReadFile(filePath) | ||
| if err != nil { | ||
| http.Error(w, "Failed to read file", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| json.NewEncoder(w).Encode(FileContent{Content: string(content)}) | ||
| } |
| import ( | ||
| "encoding/json" | ||
| "io/ioutil" | ||
| "net/http" | ||
| "os" | ||
| "path/filepath" | ||
| ) |
| func updateMemoryFile(w http.ResponseWriter, r *http.Request) { | ||
| fileName := r.PathValue("file") | ||
| homeDir, err := os.UserHomeDir() | ||
| if err != nil { | ||
| http.Error(w, "Failed to get user home directory", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| var fileContent FileContent | ||
| if err := json.NewDecoder(r.Body).Decode(&fileContent); err != nil { | ||
| http.Error(w, "Invalid request body", http.StatusBadRequest) | ||
| return | ||
| } | ||
|
|
||
| filePath := filepath.Join(homeDir, memoryDir, fileName) | ||
| if err := ioutil.WriteFile(filePath, []byte(fileContent.Content), 0644); err != nil { | ||
| http.Error(w, "Failed to write file", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| w.WriteHeader(http.StatusOK) | ||
| } |
| const ( | ||
| memoryDir = ".nanobot/workspace/memory" | ||
| skillsDir = ".nanobot/workspace/skills" | ||
| ) |
| import { onMount } from 'svelte'; | ||
| import { writable } from 'svelte/store'; | ||
|
|
||
| let activeFile = writable('SOUL.md'); | ||
| let fileContent = writable(''); | ||
| let files = ['SOUL.md', 'USER.md', 'MEMORY.md']; |
| async function saveFileContent() { | ||
| try { | ||
| const response = await fetch(`/api/memory/${$activeFile}`, { | ||
| method: 'PUT', | ||
| headers: { | ||
| 'Content-Type': 'application/json' | ||
| }, | ||
| body: JSON.stringify({ content: $fileContent }) | ||
| }); | ||
| if (!response.ok) { | ||
| console.error('Failed to save file'); | ||
| alert('Failed to save file.'); | ||
| } else { | ||
| alert('File saved successfully.'); | ||
| } | ||
| } catch (error) { | ||
| console.error('Error saving file:', error); | ||
| alert('Error saving file.'); | ||
| } | ||
| } |
|
|
||
| async function fetchFileContent(fileName: string) { | ||
| try { | ||
| const response = await fetch(`/api/memory/${fileName}`); |
| let activeFile = writable('SOUL.md'); | ||
| let fileContent = writable(''); | ||
| let files = ['SOUL.md', 'USER.md', 'MEMORY.md']; |
EntelligenceAI PR SummaryAdds end-to-end memory file read/write functionality spanning a new Go API backend and a new Svelte frontend editor component.
Confidence Score: 1/5 - Blocking IssuesNot safe to merge — the Key Findings:
Files requiring special attention
|
| return | ||
| } | ||
|
|
||
| filePath := filepath.Join(homeDir, memoryDir, fileName) |
There was a problem hiding this comment.
Path traversal: unsanitized {file} param allows arbitrary file read/write
The frontend (MemoryEditor.svelte) only sends 3 hardcoded filenames, but the backend uses r.PathValue("file") directly in filepath.Join without sanitization. Any caller can send GET /api/memory/../../.ssh/id_rsa or PUT /api/memory/../../.bashrc to read or overwrite arbitrary files under the user's home directory.
| filePath := filepath.Join(homeDir, memoryDir, fileName) | |
| fileName := r.PathValue("file") | |
| if strings.Contains(fileName, "/") || strings.Contains(fileName, "..") { | |
| http.Error(w, "Invalid file name", http.StatusBadRequest) | |
| return | |
| } | |
| allowed := map[string]bool{"SOUL.md": true, "USER.md": true, "MEMORY.md": true} | |
| if !allowed[fileName] { | |
| http.Error(w, "File not allowed", http.StatusForbidden) | |
| return | |
| } |
Prompt to fix with AI
Copy this prompt into your AI coding assistant to fix this issue.
In pkg/api/memory.go, both getMemoryFile (line 21) and updateMemoryFile (line 39) must validate the `fileName` from PathValue before using it in filepath.Join. Add: (1) a check that fileName contains no `/` or `..` components (filepath.Base trick or explicit Contains check), and (2) an allowlist check against the three permitted filenames ["SOUL.md", "USER.md", "MEMORY.md"]. Return 400/403 on violation. Apply to both handlers.
| import { writable } from 'svelte/store'; | ||
|
|
||
| let activeFile = writable('SOUL.md'); | ||
| let fileContent = writable(''); |
There was a problem hiding this comment.
Replace legacy writable stores with Svelte 5 runes
Guideline Violation (CLAUDE.md): "Use Svelte 5 runes ($state, $derived, $effect) rather than legacy store patterns". activeFile and fileContent use writable() from svelte/store; the rest of the codebase (e.g. +layout.svelte) uses $state.
Prompt to fix with AI
Copy this prompt into your AI coding assistant to fix this issue.
In `packages/ui/src/lib/components/MemoryEditor.svelte`, replace the legacy Svelte store pattern with Svelte 5 runes as required by CLAUDE.md. Remove `import { writable } from 'svelte/store'` (line 3). Change line 5 from `let activeFile = writable('SOUL.md')` to `let activeFile = $state('SOUL.md')`. Change line 6 from `let fileContent = writable('')` to `let fileContent = $state('')`. Update all `activeFile.set(x)` calls to `activeFile = x`, all `fileContent.set(x)` calls to `fileContent = x`, and all template references `$activeFile` / `$fileContent` to just `activeFile` / `fileContent`.
| function handleFileSelection(fileName: string) { | ||
| activeFile.set(fileName); | ||
| fetchFileContent(fileName); | ||
| } |
There was a problem hiding this comment.
Unsaved edits silently discarded on tab switch
When a user edits the textarea and clicks a different file tab, handleFileSelection immediately calls fetchFileContent, overwriting fileContent with the newly loaded file's content — the in-progress edits are lost without any warning or save prompt.
Prompt to fix with AI
Copy this prompt into your AI coding assistant to fix this issue.
In `packages/ui/src/lib/components/MemoryEditor.svelte`, fix `handleFileSelection` (lines 46–49) to prevent silent data loss when switching tabs. Add a dirty-state flag (e.g. `let isDirty = $state(false)`) that is set `true` whenever the textarea content changes (bind a change handler or use `$effect` comparing `fileContent` to the last-saved value). In `handleFileSelection`, if `isDirty` is true, prompt the user (e.g. `if (!confirm('You have unsaved changes. Discard them?')) return;`) before proceeding to `fetchFileContent`. Reset `isDirty` to `false` after a successful save in `saveFileContent`.
| content, err := ioutil.ReadFile(filePath) | ||
| if err != nil { | ||
| http.Error(w, "Failed to read file", http.StatusInternalServerError) | ||
| return |
There was a problem hiding this comment.
Return 404 for missing memory file instead of 500
ioutil.ReadFile fails with fs.ErrNotExist when the file doesn't exist yet, but the handler returns HTTP 500 unconditionally. The UI receives a server-error status for a normal "file not created yet" state, making it impossible to distinguish a missing file from a real failure.
| content, err := ioutil.ReadFile(filePath) | |
| if err != nil { | |
| http.Error(w, "Failed to read file", http.StatusInternalServerError) | |
| return | |
| content, err := ioutil.ReadFile(filePath) | |
| if err != nil { | |
| if os.IsNotExist(err) { | |
| http.Error(w, "File not found", http.StatusNotFound) | |
| } else { | |
| http.Error(w, "Failed to read file", http.StatusInternalServerError) | |
| } | |
| return | |
| } |
Prompt to fix with AI
Copy this prompt into your AI coding assistant to fix this issue.
In `pkg/api/memory.go`, function `getMemoryFile`, lines 29-32: the error from `ioutil.ReadFile(filePath)` is returned as HTTP 500 for all errors. Add an `os.IsNotExist(err)` check: if true, return `http.Error(w, "File not found", http.StatusNotFound)` instead of 500. This lets the UI correctly handle the case where a memory file has not been created yet.
| return | ||
| } | ||
|
|
||
| json.NewEncoder(w).Encode(FileContent{Content: string(content)}) |
There was a problem hiding this comment.
GET response missing Content-Type: application/json header
json.NewEncoder(w).Encode(...) in getMemoryFile writes JSON to the body but never sets Content-Type: application/json. The frontend (MemoryEditor.svelte:13) calls response.json() which relies on the browser inferring JSON; some HTTP middleware or proxies may reject or mishandle the untyped response.
| json.NewEncoder(w).Encode(FileContent{Content: string(content)}) | |
| w.Header().Set("Content-Type", "application/json") | |
| json.NewEncoder(w).Encode(FileContent{Content: string(content)}) |
Prompt to fix with AI
Copy this prompt into your AI coding assistant to fix this issue.
In pkg/api/memory.go getMemoryFile, add `w.Header().Set("Content-Type", "application/json")` before calling json.NewEncoder(w).Encode() on line 35.
This PR adds a memory editor to the Web UI sidebar, allowing users to view, edit, and save memory files (SOUL.md, USER.md, MEMORY.md).