Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/dashboard/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,13 @@ pub fn dismiss_user_memory(state: State<'_, AppState>, id: i64) -> Result<(), St
db::dismiss_user_memory(&mut conn, id).map_err(|e| e.to_string())
}

#[tauri::command(async)]
pub fn restore_user_memory(state: State<'_, AppState>, id: i64) -> Result<(), String> {
let path = state.get_db_path()?;
let mut conn = db::open_readwrite(&path).map_err(|e| e.to_string())?;
db::restore_user_memory(&mut conn, id).map_err(|e| e.to_string())
}

#[tauri::command(async)]
pub fn delete_user_memory(state: State<'_, AppState>, id: i64) -> Result<(), String> {
let path = state.get_db_path()?;
Expand Down
19 changes: 19 additions & 0 deletions packages/dashboard/src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8405,6 +8405,25 @@ pub fn dismiss_user_memory(conn: &mut Connection, id: i64) -> Result<(), rusqlit
Ok(())
}

pub fn restore_user_memory(conn: &mut Connection, id: i64) -> Result<(), rusqlite::Error> {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let affected = tx.execute(
"UPDATE user_memories SET status = 'active', updated_at = ?1 WHERE id = ?2 AND status = 'dismissed'",
params![now_millis(), id],
)?;
// Same invariant as dismiss/delete: user_memories render into the cached
// m[0] <user-profile> block, so a restore must bump the global
// user-profile version or running sessions keep serving the profile
// without the restored entry. The status predicate keeps affected at 0
// for a row that is already active, so a no-op restore cannot
// invalidate every running session's cached profile.
if affected > 0 {
bump_project_user_profile_version(&tx)?;
}
tx.commit()?;
Ok(())
}

pub fn delete_user_memory(conn: &mut Connection, id: i64) -> Result<(), rusqlite::Error> {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let affected = tx.execute("DELETE FROM user_memories WHERE id = ?1", params![id])?;
Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ fn main() {
commands::get_user_memories,
commands::get_user_memory_candidates,
commands::dismiss_user_memory,
commands::restore_user_memory,
commands::delete_user_memory,
commands::update_user_memory_content,
commands::delete_user_memory_candidate,
Expand Down
5 changes: 5 additions & 0 deletions packages/dashboard/src-tauri/src/serve/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,11 @@ pub async fn dispatch(state: &AppState, cmd: &str, args: Value) -> Result<Value,
let mut conn = open_readwrite(state)?;
json(db::dismiss_user_memory(&mut conn, a.id).map_err(to_command)?)
}
"restore_user_memory" => {
let a: IdArgs = parse_args(args)?;
let mut conn = open_readwrite(state)?;
json(db::restore_user_memory(&mut conn, a.id).map_err(to_command)?)
}
"delete_user_memory" => {
let a: IdArgs = parse_args(args)?;
let mut conn = open_readwrite(state)?;
Expand Down
54 changes: 54 additions & 0 deletions packages/dashboard/src-tauri/tests/db_mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,60 @@ fn dismiss_user_memory_bumps_global_user_profile_not_project_memory_epoch() {
assert_eq!(user_profile_version(&conn, "git:project-a"), 0);
}

#[test]
fn restore_user_memory_returns_row_to_active_set_and_bumps_global_profile() {
let mut conn = make_db();
conn.execute(
"INSERT INTO user_memories (content, status, promoted_at, source_candidate_ids, created_at, updated_at)
VALUES ('remember me', 'dismissed', 1, '[]', 1, 1)",
[],
)
.expect("insert user memory");
let id = conn.last_insert_rowid();
seed_project_state(&conn, "git:project-a", 4, 0);

db::restore_user_memory(&mut conn, id).expect("restore");

// The restored row is back in the active set the plugin injects from.
let active = db::get_user_memories(&conn, Some("active")).expect("active set");
assert_eq!(active.len(), 1);
assert_eq!(active[0].id, id);
assert_eq!(memory_epoch(&conn, "__global__"), 0);
assert_eq!(user_profile_version(&conn, "__global__"), 1);
assert_eq!(memory_epoch(&conn, "git:project-a"), 4);
assert_eq!(user_profile_version(&conn, "git:project-a"), 0);
}

#[test]
fn restore_user_memory_of_active_row_is_a_no_op_without_bump() {
let mut conn = make_db();
conn.execute(
"INSERT INTO user_memories (content, status, promoted_at, source_candidate_ids, created_at, updated_at)
VALUES ('remember me', 'active', 1, '[]', 1, 1)",
[],
)
.expect("insert user memory");
let id = conn.last_insert_rowid();
seed_project_state(&conn, "git:project-a", 4, 0);

// A row that is already active must not invalidate the cached profile:
// the status predicate keeps affected at 0, so no bump fires.
db::restore_user_memory(&mut conn, id).expect("restore active row");

let active = db::get_user_memories(&conn, Some("active")).expect("active set");
assert_eq!(active.len(), 1);
assert_eq!(user_profile_version(&conn, "__global__"), 0);
assert_eq!(memory_epoch(&conn, "__global__"), 0);
let updated_at: i64 = conn
.query_row(
"SELECT updated_at FROM user_memories WHERE id = ?1",
params![id],
|row| row.get(0),
)
.expect("updated_at");
assert_eq!(updated_at, 1);
}

#[test]
fn promote_user_memory_candidate_maps_columns_and_bumps_global_profile() {
let mut conn = make_db();
Expand Down
20 changes: 20 additions & 0 deletions packages/dashboard/src/components/UserMemories/UserMemories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getUserMemories,
getUserMemoryCandidates,
promoteUserMemoryCandidate,
restoreUserMemory,
updateUserMemoryContent,
} from "../../lib/api";
import FilterSelect from "../shared/FilterSelect";
Expand Down Expand Up @@ -79,6 +80,16 @@ export default function UserMemories() {
}
};

const handleRestore = async (id: number) => {
try {
setError(null);
await restoreUserMemory(id);
refetchMemories();
} catch (e: unknown) {
setError(`Failed to restore memory: ${e instanceof Error ? e.message : String(e)}`);
}
};

const handleDeleteMemory = async (id: number) => {
try {
setError(null);
Expand Down Expand Up @@ -274,6 +285,15 @@ export default function UserMemories() {
Dismiss
</button>
</Show>
<Show when={memory.status === "dismissed"}>
<button
type="button"
class="btn sm"
onClick={() => handleRestore(memory.id)}
>
Restore
</button>
</Show>
<button
type="button"
class="btn sm danger"
Expand Down
4 changes: 4 additions & 0 deletions packages/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,10 @@ export async function dismissUserMemory(id: number): Promise<void> {
return invoke("dismiss_user_memory", { id });
}

export async function restoreUserMemory(id: number): Promise<void> {
return invoke("restore_user_memory", { id });
}

export async function deleteUserMemory(id: number): Promise<void> {
return invoke("delete_user_memory", { id });
}
Expand Down
1 change: 1 addition & 0 deletions packages/docs/src/content/docs/reference/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ Live **provider cache** telemetry: per-turn cache read/write, input tokens, and
- **Promote** a candidate into an active user memory.
- **Edit** content, **dismiss**, or **delete** promoted memories.
- **Delete** candidates you do not want promoted.
- Dismiss is reversible: dismissed memories stay listed (filter by **Dismissed**), and **Restore** returns one to the active set where it is injected into `<user-profile>` again.

Collection is driven by the **`review-user-memories`** dreamer task: schedule it to collect, set its schedule to `""` to turn it off.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
insertUserMemory,
insertUserMemoryCandidates,
pruneExpiredUserMemoryCandidates,
restoreUserMemory,
USER_MEMORY_CANDIDATE_TTL_MS,
} from "./storage-user-memory";

Expand Down Expand Up @@ -63,6 +64,48 @@ describe("user-memory provenance", () => {
});
});

describe("user-memory restore", () => {
it("returns a dismissed memory to the active set", () => {
const db = freshDb();
db.prepare(
`INSERT INTO user_memories
(content, status, promoted_at, source_candidate_ids, created_at, updated_at)
VALUES (?, 'dismissed', ?, ?, ?, ?)`,
).run("Prefers concise explanations", 1, "[]", 1, 1);
expect(getActiveUserMemories(db)).toHaveLength(0);

const [row] = db.prepare("SELECT id FROM user_memories").all() as Array<{ id: number }>;
restoreUserMemory(db, row.id);

const active = getActiveUserMemories(db);
expect(active).toHaveLength(1);
expect(active[0].content).toBe("Prefers concise explanations");
db.close();
});

it("leaves an already-active memory untouched", () => {
const db = freshDb();
db.prepare(
`INSERT INTO user_memories
(content, status, promoted_at, source_candidate_ids, created_at, updated_at)
VALUES (?, 'active', ?, ?, ?, ?)`,
).run("Prefers concise explanations", 1, "[]", 1, 1);
const [row] = db.prepare("SELECT id FROM user_memories").all() as Array<{ id: number }>;

restoreUserMemory(db, row.id);

// No transition, no write: updated_at must stay at the seeded value so
// a caller that bumps on the caller side does not invalidate for a
// no-op (mirrors the Rust status predicate).
const [after] = db
.prepare("SELECT updated_at FROM user_memories WHERE id = ?")
.all(row.id) as Array<{ updated_at: number }>;
expect(after.updated_at).toBe(1);
expect(getActiveUserMemories(db)).toHaveLength(1);
db.close();
});
});

describe("user-memory candidate decay", () => {
it("prunes candidates older than the TTL, keeps fresher ones", () => {
const db = freshDb();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ export function dismissUserMemory(db: Database, id: number): void {
);
}

export function restoreUserMemory(db: Database, id: number): void {
db.prepare(
"UPDATE user_memories SET status = 'active', updated_at = ? WHERE id = ? AND status = 'dismissed'",
).run(Date.now(), id);
}

function parseCandidateIds(raw: string): number[] {
try {
const parsed = JSON.parse(raw);
Expand Down
Loading