From 63258899124239b6328cc89d0e294604554ab147 Mon Sep 17 00:00:00 2001 From: Evan Fioritto Date: Thu, 11 Jun 2026 08:36:31 -0400 Subject: [PATCH 01/59] Bug fix: halt chat generation when view not active chatpanel is now mounted to DOM --- ui/App.tsx | 46 +++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/ui/App.tsx b/ui/App.tsx index 1e449c0..ec91983 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -527,6 +527,34 @@ function App() { )} + {/* Decoupled chat panel, persists regardless of viewmode */} +
+ { + void countPendingChangesetItems() + .then(setPendingProposalCount) + .catch(console.error); + }} + /> +
+ {viewMode === "editor" ? ( selectedNodeId && ( - ) : ( - { - void countPendingChangesetItems() - .then(setPendingProposalCount) - .catch(console.error); - }} - /> - )} + ) : null} {viewMode !== "editor" && ( From 047e7e174f0d8ec8fb5d893e2dbcd46d2ef1403a Mon Sep 17 00:00:00 2001 From: Evan Fioritto Date: Thu, 11 Jun 2026 08:52:29 -0400 Subject: [PATCH 02/59] Bug fix: Non existing node connections show the same as existing connections node not found text if it is a broken connection, instead of showing the link with throw a warning --- ui/style/components/NodeEditor.css | 19 +++++ ui/utils/markdownUtils.tsx | 125 +++++++++++++++++++++-------- 2 files changed, 110 insertions(+), 34 deletions(-) diff --git a/ui/style/components/NodeEditor.css b/ui/style/components/NodeEditor.css index 4cd80cd..3eab54f 100644 --- a/ui/style/components/NodeEditor.css +++ b/ui/style/components/NodeEditor.css @@ -1453,3 +1453,22 @@ .header-action-icon-btn:active { transform: translateY(0); } + +.wikilink-badge.broken { + opacity: 0.65; + text-decoration: line-through; + color: #e53e3e; + border-color: #fc8181; + background: rgba(245, 101, 101, 0.08); + cursor: not-allowed; +} + +.wikilink-badge.broken:hover { + background: rgba(245, 101, 101, 0.12); + border-color: #f56565; +} + +.wikilink-badge.loading { + opacity: 0.7; + cursor: wait; +} diff --git a/ui/utils/markdownUtils.tsx b/ui/utils/markdownUtils.tsx index 596945b..361295a 100644 --- a/ui/utils/markdownUtils.tsx +++ b/ui/utils/markdownUtils.tsx @@ -165,7 +165,95 @@ export function preprocessMathDelimiters(text: string): string { processed = processed.replace(/\\\\\)/g, "$").replace(/\\\)/g, "$"); return processed; } +function WikiLinkBadge({ + nodeId, + children, + onSelectNode, +}: { + nodeId: string; + children: React.ReactNode; + onSelectNode?: (nodeId: string) => void; +}) { + const [nodeExists, setNodeExists] = React.useState(null); + const isSearchQuery = nodeId.startsWith("search:"); + + // Validate node existence on mount (skip for search queries) + React.useEffect(() => { + if (isSearchQuery) { + return; // Don't validate search queries + } + + let isMounted = true; + + getAllNodes() + .then((nodes) => { + if (isMounted) { + const exists = nodes.some((n) => n.id === nodeId); + setNodeExists(exists); + } + }) + .catch(() => { + if (isMounted) { + setNodeExists(false); + } + }); + + return () => { + isMounted = false; + }; + }, [nodeId, isSearchQuery]); + + const isBroken = nodeExists === false; + const isLoading = nodeExists === null && !isSearchQuery; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (isBroken) { + alert( + `⚠️ Broken Node Connection\n\nThe node "${children}" no longer exists in your vault.\n\nYou may need to:\n• Remove this link\n• Create a matching node with this title` + ); + return; + } + if (onSelectNode) { + if (isSearchQuery) { + const query = nodeId.substring(7).trim(); + getAllNodes() + .then((nodes) => { + const match = nodes.find((n) => n.title.toLowerCase().trim() === query.toLowerCase()); + if (match) { + onSelectNode(match.id); + } else { + alert(`⚠️ Node Not Found\n\nNo node with title "${query}" exists in your vault.`); + } + }) + .catch((err) => console.error("Failed to query nodes for wikilink:", err)); + } else { + onSelectNode(nodeId); + } + } + }; + + return ( + + ); +} /** * Creates stable markdown components overrides */ @@ -184,40 +272,9 @@ export function createMarkdownComponents( ?.split(/[?#]/)[0] || ""; const decodedNodeId = decodeURIComponent(nodeId); return ( - + + {children} + ); } return ( From de6c3b271afa48e828bf9bdef495ec4a637c7e08 Mon Sep 17 00:00:00 2001 From: Evan Fioritto Date: Thu, 11 Jun 2026 22:52:35 -0400 Subject: [PATCH 03/59] Correction Signal Detector (Backend Built a lightweight, local heuristic that evaluates every incoming user message for correction signals --- core/src/memory_agent/correction.rs | 91 +++++++++++++++++++ core/src/memory_agent/mod.rs | 3 +- ui/App.tsx | 1 + ui/types/generated/Backlink.ts | 8 +- ui/types/generated/Changeset.ts | 13 +-- ui/types/generated/ChangesetCommitInput.ts | 2 +- ui/types/generated/ChangesetItem.ts | 17 +--- ui/types/generated/Door.ts | 13 +-- ui/types/generated/DoorCreateInput.ts | 7 +- ui/types/generated/ItemReviewAction.ts | 2 +- ui/types/generated/Node.ts | 21 +---- ui/types/generated/NodeCreateInput.ts | 14 +-- ui/types/generated/NodeUpdateInput.ts | 16 +--- .../generated/OnboardingNodeCommitInput.ts | 10 +- ui/types/generated/OnboardingProposedNode.ts | 11 +-- ui/types/generated/Tag.ts | 2 +- ui/types/generated/TagCreateInput.ts | 2 +- ui/types/generated/Vault.ts | 17 +--- ui/types/generated/VaultCreateInput.ts | 11 +-- ui/types/generated/VaultUpdateInput.ts | 9 +- 20 files changed, 111 insertions(+), 159 deletions(-) create mode 100644 core/src/memory_agent/correction.rs diff --git a/core/src/memory_agent/correction.rs b/core/src/memory_agent/correction.rs new file mode 100644 index 0000000..6bdb601 --- /dev/null +++ b/core/src/memory_agent/correction.rs @@ -0,0 +1,91 @@ +/// This module defines the logic for detecting correction signals in user messages. + +/// Evaluates whether a user message contains correction signals. +/// Returns `Some(CorrectionSignal)` if detected, `None` otherwise. +pub fn detect_correction_signal( + message: &str, + previous_message: Option<&str>, + pending_proposed_data: &[String], +) -> Option { + let message_lower = message.to_lowercase(); + + // 1. Explicit Phrase Scan + for phrase in CORRECTION_PHRASES { + if message_lower.contains(phrase) { + return Some(CorrectionSignal::ExplicitPhrase { + phrase: phrase.to_string(), + }); + } + } + + // 2. Direct Negation Scan + if previous_message.is_some() { + let prev_lower = previous_message.unwrap().to_lowercase(); + for word in prev_lower.split_whitespace() { + // Check if current message negates a specific word/phrase from the previous message + if message_lower.contains(&format!("not {}", word)) + || message_lower.contains(&format!("no, {}", word)) + || message_lower.contains(&format!("no {}", word)) + || message_lower.contains(&format!("it's {}, not {}", word, word)) + { + return Some(CorrectionSignal::Negation { + negated_fragment: word.to_string(), + }); + } + } + } + + // Check for contradictions with pending proposed data + for pending in pending_proposed_data { + // Check if current message contradicts a pending proposed field (e.g., "not X", "X is wrong") + if message_lower.contains(&format!("not {}", pending.to_lowercase())) + || message_lower.contains(&format!("{} is wrong", pending.to_lowercase())) + { + return Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: pending.clone(), + }); + } + } + + None +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CorrectionSignal { + /// Explicit correction phrases: "actually," "wait," "I meant," "not X, Y," etc. + ExplicitPhrase { phrase: String }, + /// Direct negation of a prior message value + Negation { negated_fragment: String }, + /// Contradiction of a field in a pending changeset item + ChangesetContradiction { contradicted_field: String }, +} + +const CORRECTION_PHRASES: &[&str] = &[ + "actually", + "actually,", + "wait,", + "wait", + "i meant", + "not that", + "correction", + "correction:", + "to clarify", + "scratch that", + "never mind", + "nevermind", + "no wait", + "i was wrong", + "let me correct", + "that's wrong", + "that's not right", + "i misspoke", +]; + +/// Returns `true` if any correction signal is detected in the message. +pub fn has_correction_signal( + message: &str, + previous_message: Option<&str>, + pending_proposed_data: &[String], +) -> bool { + detect_correction_signal(message, previous_message, pending_proposed_data).is_some() +} diff --git a/core/src/memory_agent/mod.rs b/core/src/memory_agent/mod.rs index 464c7a7..7bf33c8 100644 --- a/core/src/memory_agent/mod.rs +++ b/core/src/memory_agent/mod.rs @@ -1,13 +1,14 @@ pub mod changeset; pub mod commit; +pub mod correction; pub mod parser; pub mod persistence; pub mod prompt; pub mod similarity; pub mod trigger; - pub use changeset::{build_changeset, ChangesetItemType, PendingChangeset, PendingChangesetItem}; pub use commit::commit_changeset_transaction; +pub use correction::{detect_correction_signal, has_correction_signal, CorrectionSignal}; pub use parser::{ parse_candidates_from_llm_output, parse_candidates_json, CandidateAction, CandidateNode, }; diff --git a/ui/App.tsx b/ui/App.tsx index ec91983..e9f7b43 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -534,6 +534,7 @@ function App() { inset: 0, display: viewMode === "chat" ? "flex" : "none", flexDirection: "column", + alignItems: "center", pointerEvents: viewMode === "chat" ? "auto" : "none", overflow: "hidden", }} diff --git a/ui/types/generated/Backlink.ts b/ui/types/generated/Backlink.ts index 807cfdc..0cd2e51 100644 --- a/ui/types/generated/Backlink.ts +++ b/ui/types/generated/Backlink.ts @@ -1,9 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Backlink = { - id: string; - targetNodeId: string; - sourceNodeId: string; - doorId: string; - createdAt: string; -}; +export type Backlink = { id: string, targetNodeId: string, sourceNodeId: string, doorId: string, createdAt: string, }; diff --git a/ui/types/generated/Changeset.ts b/ui/types/generated/Changeset.ts index f38039d..e291692 100644 --- a/ui/types/generated/Changeset.ts +++ b/ui/types/generated/Changeset.ts @@ -1,14 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Changeset = { - id: string; - sessionId: string | null; - status: string; - itemCount: number; - acceptedCount: number; - dismissedCount: number; - modelUsed: string | null; - createdAt: string; - reviewedAt: string | null; - summary: string | null; -}; +export type Changeset = { id: string, sessionId: string | null, status: string, itemCount: number, acceptedCount: number, dismissedCount: number, modelUsed: string | null, createdAt: string, reviewedAt: string | null, summary: string | null, }; diff --git a/ui/types/generated/ChangesetCommitInput.ts b/ui/types/generated/ChangesetCommitInput.ts index d4d5bde..ddbc530 100644 --- a/ui/types/generated/ChangesetCommitInput.ts +++ b/ui/types/generated/ChangesetCommitInput.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ItemReviewAction } from "./ItemReviewAction"; -export type ChangesetCommitInput = { changesetId: string; itemActions: Array }; +export type ChangesetCommitInput = { changesetId: string, itemActions: Array, }; diff --git a/ui/types/generated/ChangesetItem.ts b/ui/types/generated/ChangesetItem.ts index 1f0ba8d..4b8928a 100644 --- a/ui/types/generated/ChangesetItem.ts +++ b/ui/types/generated/ChangesetItem.ts @@ -1,18 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ChangesetItem = { - id: string; - changesetId: string; - itemType: string; - targetNodeId: string | null; - proposedData: string; - existingData: string | null; - similarity: number | null; - mergeWithId: string | null; - doorId: string | null; - status: string; - reviewedAt: string | null; - sortOrder: number; - crossVaultAnomaly: boolean; - anomalyWarning: string | null; -}; +export type ChangesetItem = { id: string, changesetId: string, itemType: string, targetNodeId: string | null, proposedData: string, existingData: string | null, similarity: number | null, mergeWithId: string | null, doorId: string | null, status: string, reviewedAt: string | null, sortOrder: number, crossVaultAnomaly: boolean, anomalyWarning: string | null, }; diff --git a/ui/types/generated/Door.ts b/ui/types/generated/Door.ts index 633a47c..8e162d1 100644 --- a/ui/types/generated/Door.ts +++ b/ui/types/generated/Door.ts @@ -1,14 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Door = { - id: string; - sourceNodeId: string; - targetNodeId: string | null; - targetVaultId: string | null; - label: string | null; - status: string; - orphanReason: string | null; - orphanSince: string | null; - createdAt: string; - updatedAt: string; -}; +export type Door = { id: string, sourceNodeId: string, targetNodeId: string | null, targetVaultId: string | null, label: string | null, status: string, orphanReason: string | null, orphanSince: string | null, createdAt: string, updatedAt: string, }; diff --git a/ui/types/generated/DoorCreateInput.ts b/ui/types/generated/DoorCreateInput.ts index 0b0a4ef..8656680 100644 --- a/ui/types/generated/DoorCreateInput.ts +++ b/ui/types/generated/DoorCreateInput.ts @@ -1,8 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DoorCreateInput = { - sourceNodeId: string; - targetNodeId?: string; - targetVaultId?: string; - label?: string; -}; +export type DoorCreateInput = { sourceNodeId: string, targetNodeId?: string, targetVaultId?: string, label?: string, }; diff --git a/ui/types/generated/ItemReviewAction.ts b/ui/types/generated/ItemReviewAction.ts index 9be6223..01e25d5 100644 --- a/ui/types/generated/ItemReviewAction.ts +++ b/ui/types/generated/ItemReviewAction.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ItemReviewAction = { itemId: string; action: string; editedData: unknown }; +export type ItemReviewAction = { itemId: string, action: string, editedData: unknown, }; diff --git a/ui/types/generated/Node.ts b/ui/types/generated/Node.ts index e234307..3b93cab 100644 --- a/ui/types/generated/Node.ts +++ b/ui/types/generated/Node.ts @@ -1,22 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Node = { - id: string; - vaultId: string; - subVaultId: string | null; - nodeType: string; - title: string; - summary: string; - detail: string | null; - source: string | null; - sourceType: string | null; - privacyTier: string | null; - priority: string; - version: number; - isArchived: boolean; - createdAt: string; - updatedAt: string; - lastAccessed: string; - deletedAt: string | null; - meta: string; -}; +export type Node = { id: string, vaultId: string, subVaultId: string | null, nodeType: string, title: string, summary: string, detail: string | null, source: string | null, sourceType: string | null, privacyTier: string | null, priority: string, version: number, isArchived: boolean, createdAt: string, updatedAt: string, lastAccessed: string, deletedAt: string | null, meta: string, }; diff --git a/ui/types/generated/NodeCreateInput.ts b/ui/types/generated/NodeCreateInput.ts index 81f3ad4..15729ca 100644 --- a/ui/types/generated/NodeCreateInput.ts +++ b/ui/types/generated/NodeCreateInput.ts @@ -1,15 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NodeCreateInput = { - vaultId: string; - subVaultId?: string; - nodeType?: string; - title: string; - summary: string; - detail?: string; - source?: string; - sourceType?: string; - privacyTier?: string; - priority?: string; - meta?: string; -}; +export type NodeCreateInput = { vaultId: string, subVaultId?: string, nodeType?: string, title: string, summary: string, detail?: string, source?: string, sourceType?: string, privacyTier?: string, priority?: string, meta?: string, }; diff --git a/ui/types/generated/NodeUpdateInput.ts b/ui/types/generated/NodeUpdateInput.ts index 21603da..c909731 100644 --- a/ui/types/generated/NodeUpdateInput.ts +++ b/ui/types/generated/NodeUpdateInput.ts @@ -1,17 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NodeUpdateInput = { - id: string; - vaultId?: string; - subVaultId?: string; - nodeType?: string; - title?: string; - summary?: string; - detail?: string; - source?: string; - sourceType?: string; - privacyTier?: string; - priority?: string; - isArchived?: boolean; - meta?: string; -}; +export type NodeUpdateInput = { id: string, vaultId?: string, subVaultId?: string, nodeType?: string, title?: string, summary?: string, detail?: string, source?: string, sourceType?: string, privacyTier?: string, priority?: string, isArchived?: boolean, meta?: string, }; diff --git a/ui/types/generated/OnboardingNodeCommitInput.ts b/ui/types/generated/OnboardingNodeCommitInput.ts index 6bc89e5..888c034 100644 --- a/ui/types/generated/OnboardingNodeCommitInput.ts +++ b/ui/types/generated/OnboardingNodeCommitInput.ts @@ -3,12 +3,4 @@ /** * Payload for committing accepted onboarding rows to persistent nodes. */ -export type OnboardingNodeCommitInput = { - vaultId: string; - title: string; - summary: string; - detail?: string; - nodeType?: string; - sourceType?: string; - tags?: Array; -}; +export type OnboardingNodeCommitInput = { vaultId: string, title: string, summary: string, detail?: string, nodeType?: string, sourceType?: string, tags?: Array, }; diff --git a/ui/types/generated/OnboardingProposedNode.ts b/ui/types/generated/OnboardingProposedNode.ts index dd81d32..f851ef6 100644 --- a/ui/types/generated/OnboardingProposedNode.ts +++ b/ui/types/generated/OnboardingProposedNode.ts @@ -3,13 +3,4 @@ /** * Serialized onboarding extraction result for IPC / TypeScript. Enriches `onboarding::ProposedNode` with backend-derived metadata for the UI like `resolved_vault_id`. */ -export type OnboardingProposedNode = { - title: string; - summary: string; - detail?: string; - category?: string; - targetVaultKey?: string; - tags?: Array; - nodeType?: string; - resolvedVaultId?: string; -}; +export type OnboardingProposedNode = { title: string, summary: string, detail?: string, category?: string, targetVaultKey?: string, tags?: Array, nodeType?: string, resolvedVaultId?: string, }; diff --git a/ui/types/generated/Tag.ts b/ui/types/generated/Tag.ts index 230b753..2cdc3a5 100644 --- a/ui/types/generated/Tag.ts +++ b/ui/types/generated/Tag.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Tag = { id: string; name: string; color: string | null; createdAt: string }; +export type Tag = { id: string, name: string, color: string | null, createdAt: string, }; diff --git a/ui/types/generated/TagCreateInput.ts b/ui/types/generated/TagCreateInput.ts index d94268a..a54c568 100644 --- a/ui/types/generated/TagCreateInput.ts +++ b/ui/types/generated/TagCreateInput.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type TagCreateInput = { name: string; color?: string }; +export type TagCreateInput = { name: string, color?: string, }; diff --git a/ui/types/generated/Vault.ts b/ui/types/generated/Vault.ts index dbc8099..d201d30 100644 --- a/ui/types/generated/Vault.ts +++ b/ui/types/generated/Vault.ts @@ -1,18 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Vault = { - id: string; - parentVaultId?: string; - name: string; - icon: string | null; - description: string | null; - privacyTier: string; - priorityProfile: string; - summaryNodeId: string | null; - sortOrder: number; - createdAt: string; - updatedAt: string; - deletedAt: string | null; - meta: string; - uiMetadata: string; -}; +export type Vault = { id: string, parentVaultId?: string, name: string, icon: string | null, description: string | null, privacyTier: string, priorityProfile: string, summaryNodeId: string | null, sortOrder: number, createdAt: string, updatedAt: string, deletedAt: string | null, meta: string, uiMetadata: string, }; diff --git a/ui/types/generated/VaultCreateInput.ts b/ui/types/generated/VaultCreateInput.ts index fa3d07f..8bd15be 100644 --- a/ui/types/generated/VaultCreateInput.ts +++ b/ui/types/generated/VaultCreateInput.ts @@ -1,12 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type VaultCreateInput = { - name: string; - parentVaultId?: string; - icon?: string; - description?: string; - privacyTier?: string; - priorityProfile?: string; - sortOrder?: number; - meta?: string; -}; +export type VaultCreateInput = { name: string, parentVaultId?: string, icon?: string, description?: string, privacyTier?: string, priorityProfile?: string, sortOrder?: number, meta?: string, }; diff --git a/ui/types/generated/VaultUpdateInput.ts b/ui/types/generated/VaultUpdateInput.ts index fcea418..409a9cc 100644 --- a/ui/types/generated/VaultUpdateInput.ts +++ b/ui/types/generated/VaultUpdateInput.ts @@ -1,10 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type VaultUpdateInput = { - id: string; - name?: string; - privacyTier?: string; - priorityProfile?: string; - icon?: string; - description?: string; -}; +export type VaultUpdateInput = { id: string, name?: string, privacyTier?: string, priorityProfile?: string, icon?: string, description?: string, }; From 70f97271738d7c6fb2514cf8c39c500ed1797994 Mon Sep 17 00:00:00 2001 From: Evan Fioritto Date: Sat, 13 Jun 2026 13:12:44 -0400 Subject: [PATCH 04/59] Correction-Triggered Extraction --- core/src/lib.rs | 30 +++++++++++++++++++- core/src/memory_agent/mod.rs | 4 ++- core/src/memory_agent/trigger.rs | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index 5c76dde..464406d 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use chat::ChatMessage; +use rusqlite::OptionalExtension; use rusqlite::{params, Connection, Row}; use serde::Serialize; use tauri::Manager; @@ -629,6 +630,22 @@ async fn memory_extract( execute_memory_extraction_pipeline(provider, endpoint, model, db_path).await } +fn fetch_latest_user_message( + conn: &Connection, + session_id: &str, +) -> Result, String> { + conn.query_row( + "SELECT content FROM session_messages + WHERE session_id = ?1 AND role = 'user' + ORDER BY created_at DESC, id DESC + LIMIT 1;", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("Failed querying latest user message: {err}")) +} + #[tauri::command] async fn memory_extract_if_ready( provider: String, @@ -657,7 +674,18 @@ async fn memory_extract_if_ready( memory_agent::trigger::align_last_extract_count(&conn, current_message_count)?; // 5. Check trigger - let ready = memory_agent::trigger::should_extract(&conn, session_id)?; + let mut ready = memory_agent::trigger::should_extract(&conn, session_id)?; + if !ready { + let latest_user_msg = fetch_latest_user_message(&conn, session_id)?; + if let Some(msg_content) = latest_user_msg { + let correction_ready = + memory_agent::trigger::should_extract_correction(&conn, session_id, &msg_content)?; + if correction_ready { + ready = true; + } + } + } + if !ready { return Ok(None); } diff --git a/core/src/memory_agent/mod.rs b/core/src/memory_agent/mod.rs index 7bf33c8..d2d4d5f 100644 --- a/core/src/memory_agent/mod.rs +++ b/core/src/memory_agent/mod.rs @@ -21,4 +21,6 @@ pub use similarity::{ classify_similarity, compute_text_similarity, jaccard_similarity, tokenize, SimilarityClass, SIMILARITY_DUPLICATE, SIMILARITY_FLAG, }; -pub use trigger::{align_last_extract_count, mark_extraction_complete, should_extract}; +pub use trigger::{ + align_last_extract_count, mark_extraction_complete, should_extract, should_extract_correction, +}; diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index 16432c5..a6ea5d9 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -1,3 +1,4 @@ +use crate::memory_agent::correction; use rusqlite::{params, Connection, OptionalExtension}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -254,3 +255,49 @@ mod tests { Ok(()) } } + +/// Evaluates whether a correction signal should bypass the standard debounce gate. +/// Returns true if a correction was detected AND there are at least 3 messages +/// in the session (minimum viable context for extraction). +pub fn should_extract_correction( + conn: &Connection, + session_id: &str, + message: &str, +) -> Result { + // 1. Query latest user message prior to this one in session + let previous_message: Option = conn + .query_row( + "SELECT content FROM session_messages WHERE session_id = ?1 ORDER BY created_at DESC LIMIT 1;", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("Failed querying latest message: {err}"))?; + + // 2. Query all pending changeset_items with status 'pending' and extract their proposed_data column values + let pending_data: Vec = conn + .prepare("SELECT proposed_data FROM changeset_items WHERE status = 'pending';") + .map_err(|err| format!("Failed preparing pending changeset query: {err}"))? + .query_map([], |row| row.get(0)) + .map_err(|err| format!("Failed querying pending changeset items: {err}"))? + .collect::, _>>() + .map_err(|err| format!("Failed reading pending changeset row: {err}"))?; + + let signal: bool = + correction::has_correction_signal(message, previous_message.as_deref(), &pending_data); + + // 3. Check for signal detection and message count threshold (3) + let message_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_messages WHERE session_id = ?1;", + [session_id], + |row| row.get(0), + ) + .map_err(|err| format!("Failed querying session message count: {err}"))?; + + if signal && message_count >= 3 { + Ok(true) + } else { + Ok(false) + } +} From 2781cba3752e788730cc35ff3278eeb853425bb4 Mon Sep 17 00:00:00 2001 From: Evan Fioritto Date: Sat, 13 Jun 2026 17:10:20 -0400 Subject: [PATCH 05/59] =?UTF-8?q?Commit=203=20=E2=80=94=20Pending=20Change?= =?UTF-8?q?set=20Amendment=20Engine=20(Backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/lib.rs | 31 ++- core/src/memory_agent/ammendment.rs | 321 ++++++++++++++++++++++++++++ core/src/memory_agent/mod.rs | 2 + core/tests/memory_agent.rs | 3 + 4 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 core/src/memory_agent/ammendment.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index 464406d..840c335 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -412,6 +412,7 @@ pub async fn execute_memory_extraction_pipeline( endpoint: String, model: String, db_path: PathBuf, + is_correction: Option, ) -> Result { // 1. Load and filter chat history synchronously within scoped block to drop connection before await let chat_history = { @@ -593,17 +594,35 @@ pub async fn execute_memory_extraction_pipeline( // 7. Reuse a single connection for changeset build, persist, and retrieval let mut conn = open_connection(&db_path)?; - let changeset_id = { + let changeset_id: String = if is_correction.unwrap_or(false) { + let correction_signal = { + let latest = chat_history.last().map(|m| m.content.as_str()); + let previous = chat_history + .len() + .checked_sub(2) + .map(|i| chat_history[i].content.as_str()); + memory_agent::correction::detect_correction_signal(latest.unwrap_or(""), previous, &[]) + .unwrap_or(memory_agent::correction::CorrectionSignal::ExplicitPhrase { + phrase: "correction".to_string(), + }) + }; + + let (id, _amended) = memory_agent::ammendment::amend_or_create_changeset( + &mut conn, + &candidates, + "default-session", + &model, + &correction_signal, + )?; + id + } else { let tx = conn .transaction() .map_err(|err| format!("Failed to start transaction: {err}"))?; - let pending_changeset = memory_agent::changeset::build_changeset(&tx, &candidates, "default-session")?; - let persisted_id = memory_agent::persistence::persist_changeset(&tx, &pending_changeset, Some(&model))?; - tx.commit() .map_err(|err| format!("Failed to commit transaction: {err}"))?; persisted_id @@ -627,7 +646,7 @@ async fn memory_extract( // 2. Execute shared pipeline let db_path = state.db_path.clone(); - execute_memory_extraction_pipeline(provider, endpoint, model, db_path).await + execute_memory_extraction_pipeline(provider, endpoint, model, db_path, None).await } fn fetch_latest_user_message( @@ -696,7 +715,7 @@ async fn memory_extract_if_ready( // 5. Execute shared pipeline (capture result without early-returning on error, // so we always mark the extraction as attempted and respect cooldown windows) let pipeline_result = - execute_memory_extraction_pipeline(provider, endpoint, model, db_path.clone()).await; + execute_memory_extraction_pipeline(provider, endpoint, model, db_path.clone(), None).await; // 6. Mark extraction complete/attempted *before* propagating any error, // so that should_extract respects the 6-message and 2-minute cooldown diff --git a/core/src/memory_agent/ammendment.rs b/core/src/memory_agent/ammendment.rs new file mode 100644 index 0000000..a4aa06c --- /dev/null +++ b/core/src/memory_agent/ammendment.rs @@ -0,0 +1,321 @@ +use rusqlite::{params, Connection}; +use serde_json; + +use crate::memory_agent; +use std::time::{SystemTime, UNIX_EPOCH}; + +// ───────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn chrono_now_iso() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + + let secs = now.as_secs(); + let millis = now.subsec_millis(); + + // Manual decomposition of Unix timestamp → UTC date/time components + let s = secs % 60; + let m = (secs / 60) % 60; + let h = (secs / 3600) % 24; + + // Days since epoch → Gregorian calendar + let mut days = secs / 86400; + let mut year = 1970u64; + loop { + let days_in_year = if is_leap(year) { 366 } else { 365 }; + if days < days_in_year { + break; + } + days -= days_in_year; + year += 1; + } + let months = [ + 31, + if is_leap(year) { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut month = 1u64; + for &days_in_month in &months { + if days < days_in_month { + break; + } + days -= days_in_month; + month += 1; + } + let day = days + 1; + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", + year, month, day, h, m, s, millis + ) +} + +fn is_leap(year: u64) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +/// Computes Jaccard similarity between two strings at the whitespace-token level. +fn jaccard_similarity(a: &str, b: &str) -> f64 { + let set_a: std::collections::HashSet<&str> = a.split_whitespace().collect(); + let set_b: std::collections::HashSet<&str> = b.split_whitespace().collect(); + + if set_a.is_empty() && set_b.is_empty() { + return 1.0; + } + + let intersection = set_a.intersection(&set_b).count(); + let union = set_a.union(&set_b).count(); + + if union == 0 { + 0.0 + } else { + intersection as f64 / union as f64 + } +} + +/// Extracts a lowercase `"title summary"` string from a proposed_data JSON value +/// for use as the Jaccard comparison fingerprint. +fn candidate_fingerprint(proposed_data: &serde_json::Value) -> String { + let title = proposed_data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + let summary = proposed_data + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + format!("{} {}", title, summary) +} + +/// Injects `_amended` metadata into an existing proposed_data JSON value. +/// The key is prefixed with `_` so itrust-analyzer-diagnostics-view:/diagnostic%20message%20%5B4%5D?4#file:///Users/fiorittoev/Desktop/projects/MindVault/core/src/memory_agent/ammendment.rs sorts before all plain field names under +/// BTreeMap (serde_json default) and is also first when `preserve_order` / +/// IndexMap is enabled (explicit prepend). +/// +/// Only called on the UPDATE (similarity-match) path — new inserts stay clean. +fn stamp_amended( + proposed_data: serde_json::Value, + similarity: f64, + correction_signal: &crate::memory_agent::CorrectionSignal, +) -> Result { + let obj = proposed_data + .as_object() + .ok_or_else(|| "proposed_data is not a JSON object".to_string())?; + + let mut ordered = serde_json::Map::new(); + + // Insert `_amended` first so it leads the object regardless of feature flags. + ordered.insert( + "_amended".to_string(), + serde_json::json!({ + "at": chrono_now_iso(), + "similarity": similarity, + "reason": format!("{:?}", correction_signal), + }), + ); + + for (k, v) in obj.iter() { + ordered.insert(k.clone(), v.clone()); + } + + Ok(serde_json::Value::Object(ordered)) +} + +/// Finds the most-recent pending changeset for a (session_id, model) pair. +/// Returns `Some((changeset_id, content))` or `None`. +fn find_pending_changeset( + conn: &Connection, + session_id: &str, + model: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT id FROM changesets + WHERE session_id = ?1 AND model_used = ?2 AND status = 'pending' + ORDER BY created_at DESC LIMIT 1", + ) + .map_err(|e| format!("Database error: {}", e))?; + + let mut rows = stmt + .query(params![session_id, model]) + .map_err(|e| format!("Database error: {}", e))?; + + if let Some(row) = rows.next().map_err(|e| format!("Database error: {}", e))? { + let id: String = row.get(0).map_err(|e| format!("Database error: {}", e))?; + let content: String = row.get(1).map_err(|e| format!("Database error: {}", e))?; + Ok(Some((id, content))) + } else { + Ok(None) + } +} + +/// Loads all `changeset_items` rows for a given changeset, returning +/// `(item_id, proposed_data)` pairs. Runs inside the caller's transaction. +fn load_pending_items( + conn: &Connection, + changeset_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT id, proposed_data FROM changeset_items \ + WHERE changeset_id = ?1", + ) + .map_err(|e| format!("Database error: {}", e))?; + + let mut rows = stmt + .query(params![changeset_id]) + .map_err(|e| format!("Database error: {}", e))?; + + let mut items = Vec::new(); + while let Some(row) = rows.next().map_err(|e| format!("Database error: {}", e))? { + let item_id: String = row.get(0).map_err(|e| format!("Database error: {}", e))?; + let proposed_raw: String = row.get(1).map_err(|e| format!("Database error: {}", e))?; + let proposed_data: serde_json::Value = serde_json::from_str(&proposed_raw) + .map_err(|e| format!("JSON parse error on item {}: {}", item_id, e))?; + items.push((item_id, proposed_data)); + } + + Ok(items) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Either amends an existing pending changeset or creates a fresh one. +/// +/// Returns `(changeset_id, amended)` where `amended` is: +/// - `false` — a brand-new changeset was created. +/// - `true` — an existing pending changeset was updated in-place. +/// +/// `correction_signal` is used only on the amend path to populate `_amended.reason` +/// in `proposed_data`; it is ignored when creating a new changeset. +pub fn amend_or_create_changeset( + conn: &mut Connection, + candidates: &[crate::memory_agent::CandidateNode], + session_id: &str, + model: &str, + correction_signal: &crate::memory_agent::CorrectionSignal, +) -> Result<(String, bool), String> { + // ── 1. Check for an existing pending changeset ──────────────────────────── + let existing_changeset = find_pending_changeset(conn, session_id, model)?; + + // ── 2a. No pending changeset — create a fresh one ──────────────────────── + if existing_changeset.is_none() { + let changeset_id = { + let tx = conn + .transaction() + .map_err(|err| format!("Failed to start transaction: {err}"))?; + + let pending_changeset = + memory_agent::changeset::build_changeset(&tx, candidates, session_id)?; + + let persisted_id = + memory_agent::persistence::persist_changeset(&tx, &pending_changeset, Some(model))?; + + tx.commit() + .map_err(|err| format!("Failed to commit transaction: {err}"))?; + + persisted_id + }; + + return Ok((changeset_id, false)); + } + + // ── 2b. Pending changeset exists — amend in-place where possible ───────── + let (existing_id, _content) = existing_changeset.unwrap(); + + let tx = conn + .transaction() + .map_err(|err| format!("Failed to start transaction: {err}"))?; + + // Load existing items once; all comparisons run against this snapshot. + let pending_items = load_pending_items(&tx, &existing_id)?; + + for candidate in candidates { + // Build the base proposed_data for this candidate. + let candidate_data = serde_json::json!({ + "title": candidate.title, + "summary": candidate.summary, + }); + + let candidate_fp = candidate_fingerprint(&candidate_data); + + // Find the highest-similarity existing item above the 50 % threshold. + let best_match = pending_items + .iter() + .map(|(item_id, existing_data)| { + let existing_fp = candidate_fingerprint(existing_data); + let sim = jaccard_similarity(&candidate_fp, &existing_fp); + (item_id, sim) + }) + .filter(|(_, sim)| *sim > 0.5) + .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((matched_id, similarity)) = best_match { + // ── UPDATE path: same candidate, corrected values ───────────────── + // + // Stamp `_amended` metadata so the Diff Panel can render the + // (amended) badge without a schema migration. + let amended_data = stamp_amended(candidate_data, similarity, correction_signal) + .map_err(|e| format!("Failed to stamp _amended on item {}: {}", matched_id, e))?; + + let proposed_json = serde_json::to_string(&amended_data) + .map_err(|e| format!("JSON serialization error: {}", e))?; + + tx.execute( + "UPDATE changeset_items + SET proposed_data = ?1, + similarity = ?2, + reviewed_at = NULL, + sort_order = sort_order + WHERE id = ?3", + params![proposed_json, similarity, matched_id], + ) + .map_err(|e| format!("Failed to update changeset_item {}: {}", matched_id, e))?; + } else { + // ── INSERT path: genuinely new candidate, no _amended stamp ─────── + let new_item_id = crate::generate_id(&tx, "item") + .map_err(|e| format!("Failed generating item id: {e}"))?; + let proposed_json = serde_json::to_string(&candidate_data) + .map_err(|e| format!("JSON serialization error: {}", e))?; + + tx.execute( + "INSERT INTO changeset_items + (id, changeset_id, proposed_data, reviewed_at, sort_order) + VALUES (?1, ?2, ?3, NULL, + COALESCE( + (SELECT MAX(sort_order) + 1 FROM changeset_items WHERE changeset_id = ?2), + 0 + ))", + params![new_item_id, existing_id, proposed_json], + ) + .map_err(|e| format!("Failed to insert new changeset_item: {}", e))?; + + tx.execute( + "UPDATE changesets SET item_count = item_count + 1 WHERE id = ?1", + params![existing_id], + ) + .map_err(|e| format!("Failed to increment item_count: {}", e))?; + } + } + + tx.commit() + .map_err(|err| format!("Failed to commit transaction: {err}"))?; + + Ok((existing_id, true)) +} diff --git a/core/src/memory_agent/mod.rs b/core/src/memory_agent/mod.rs index d2d4d5f..4cff6f3 100644 --- a/core/src/memory_agent/mod.rs +++ b/core/src/memory_agent/mod.rs @@ -1,3 +1,4 @@ +pub mod ammendment; pub mod changeset; pub mod commit; pub mod correction; @@ -6,6 +7,7 @@ pub mod persistence; pub mod prompt; pub mod similarity; pub mod trigger; +pub use ammendment::amend_or_create_changeset; pub use changeset::{build_changeset, ChangesetItemType, PendingChangeset, PendingChangesetItem}; pub use commit::commit_changeset_transaction; pub use correction::{detect_correction_signal, has_correction_signal, CorrectionSignal}; diff --git a/core/tests/memory_agent.rs b/core/tests/memory_agent.rs index ae6313d..af5d1d3 100644 --- a/core/tests/memory_agent.rs +++ b/core/tests/memory_agent.rs @@ -444,6 +444,7 @@ fn test_privacy_filtering_excludes_redacted_and_locked() -> Result<(), Box Result format!("http://127.0.0.1:{}", port), "granite".to_string(), db_path.clone(), + None, ) .await; @@ -639,6 +641,7 @@ fn test_full_pipeline_successful_extraction_and_persistence() -> Result<(), Box< format!("http://127.0.0.1:{}", port), "granite".to_string(), db_path.clone(), + None, ) .await; From 3fc84e7a49d342b98c337f12757c0950dbcebad0 Mon Sep 17 00:00:00 2001 From: Evan Fioritto Date: Sat, 13 Jun 2026 17:36:40 -0400 Subject: [PATCH 06/59] Manual "Extract Now" Trigger --- core/src/lib.rs | 38 +++++++++++++++++ ui/components/ChatPanel.tsx | 71 ++++++++++++++++++++++++++++++- ui/ipc.ts | 12 ++++++ ui/services/memoryAgent.ts | 15 +++++++ ui/style/components/ChatPanel.css | 19 +++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index 840c335..7da5108 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -3569,6 +3569,7 @@ pub fn run() { save_markdown_file, memory_extract, memory_extract_if_ready, + memory_extract_force, changeset_count_pending, changeset_list_pending, changeset_list_items, @@ -3582,3 +3583,40 @@ pub fn run() { std::process::exit(1); }); } + +#[tauri::command] +async fn memory_extract_force( + provider: String, + endpoint: String, + model: String, + state: tauri::State<'_, AppState>, +) -> Result { + check_rate_limit("memory_agent")?; + let db_path = state.db_path.clone(); + let conn = open_connection(&db_path)?; + + // Verify minimum message threshold (at least 3 messages) + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_messages WHERE session_id = 'default-session';", + [], + |row| row.get(0), + ) + .map_err(|err| format!("Failed querying message count: {err}"))?; + if count < 3 { + return Err("Need at least 3 messages to extract memory.".to_string()); + } + + drop(conn); + + // Execute pipeline (always as correction=true to enable amendment) + let result = + execute_memory_extraction_pipeline(provider, endpoint, model, db_path.clone(), Some(true)) + .await; + + // Mark extraction complete + let conn = open_connection(&db_path)?; + memory_agent::trigger::mark_extraction_complete(&conn, count)?; + + result +} diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 181fd85..7fdaf2e 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -26,7 +26,7 @@ import { import { chatWithScope } from "../services/nodes"; import { getSetting } from "../services/settings"; import { listVaults } from "../services/vaults"; -import { extractMemoryIfReady } from "../services/memoryAgent"; +import { extractMemoryIfReady, extractMemoryForce } from "../services/memoryAgent"; import { getLlmModel, getLlmProvider, @@ -344,6 +344,8 @@ function ChatPanel({ const setChatChartsEnabled = useUIStore((state) => state.setChatChartsEnabled); const [showChartsConfirmModal, setShowChartsConfirmModal] = useState(false); + const [isExtracting, setIsExtracting] = useState(false); + useEffect(() => { onModalToggle?.(showChartsConfirmModal); }, [showChartsConfirmModal, onModalToggle]); @@ -359,6 +361,39 @@ function ChatPanel({ } }, [chartsEnabled, setChatChartsEnabled]); + async function resolveLlmConfig(): Promise<{ + provider: string; + endpoint: string; + model: string; + }> { + const provider = getLlmProvider(); + let endpoint = ""; + if (provider === "lmstudio") { + endpoint = getLmStudioEndpoint(); + } else if (provider === "ollama") { + endpoint = getOllamaEndpoint(); + } else if (["openai", "anthropic", "google", "xai"].includes(provider)) { + endpoint = await getApiKey(provider); + } + const model = getLlmModel(); + return { provider, endpoint, model }; + } + + const handleForceExtract = useCallback(async () => { + if (isExtracting || isSending) return; + setIsExtracting(true); + try { + const { provider, endpoint, model } = await resolveLlmConfig(); + await extractMemoryForce(provider, endpoint, model); + onRefreshPendingCount?.(); + } catch (err) { + console.error("Manual extraction failed:", err); + setStatus(String(err)); + } finally { + setIsExtracting(false); + } + }, [isExtracting, isSending, onRefreshPendingCount]); + const threadEndRef = useRef(null); const inputRef = useRef(null); const editInputRef = useRef(null); @@ -820,6 +855,14 @@ function ChatPanel({ > ➔ +
@@ -989,6 +1032,19 @@ function ChatPanel({
)} + + {/* Pill 4: Extract Memory */} +
+ +
{status &&

{status}

} @@ -1247,7 +1303,18 @@ function ChatPanel({ )} - + {/* Pill 4: Extract Memory */} +
+ +
{/* Overflow dropdown trigger */}
({ ok }) as IpcResult) .catch((error) => ({ err: String(error) }) as IpcResult); } + +export function memoryExtractForce( + provider: string, + endpoint: string, + model: string +): Promise> { + return invoke>("memory_extract_force", { + provider, + endpoint, + model, + }); +} diff --git a/ui/services/memoryAgent.ts b/ui/services/memoryAgent.ts index 144e8d5..afdd1f1 100644 --- a/ui/services/memoryAgent.ts +++ b/ui/services/memoryAgent.ts @@ -10,6 +10,7 @@ import { type ChangesetCommitInput, changesetListResolved, debugSeedChangeset, + memoryExtractForce, } from "../ipc"; import { clearNodesCache } from "./nodes"; import { unwrapIpcResult } from "./ipcResult"; @@ -42,6 +43,20 @@ export async function extractMemoryIfReady( return result; } +/** + * Forces an immediate memory extraction, bypassing the debounce gate. + * Used by the manual "Extract Now" chat toolbar button. + */ +export async function extractMemoryForce( + provider: string, + endpoint: string, + model: string +): Promise { + const result = await unwrapIpcResult(memoryExtractForce(provider, endpoint, model)); + clearNodesCache(); + return result; +} + /** * Counts total pending changeset items. */ diff --git a/ui/style/components/ChatPanel.css b/ui/style/components/ChatPanel.css index a4d6398..694be08 100644 --- a/ui/style/components/ChatPanel.css +++ b/ui/style/components/ChatPanel.css @@ -2234,3 +2234,22 @@ .chat-message-user .chat-show-more-btn:hover { color: #ffffff; } + +.extract-pill { + border-color: rgba(123, 97, 255, 0.25); +} + +.extract-pill:hover:not(:disabled) { + border-color: rgba(123, 97, 255, 0.5); + background: rgba(123, 97, 255, 0.08); +} + +.extract-pill:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.extract-pill.active { + background: rgba(123, 97, 255, 0.12); + border-color: rgba(123, 97, 255, 0.45); +} From 8511e2d8d1ee5459f654f0ca5fb0d8c971f29674 Mon Sep 17 00:00:00 2001 From: Evan Fioritto Date: Sun, 14 Jun 2026 13:08:46 -0400 Subject: [PATCH 07/59] Diff Panel Amendment Badge & Integration Tests --- core/Cargo.toml | 2 +- core/src/lib.rs | 18 +- .../{ammendment.rs => amendment.rs} | 15 +- core/src/memory_agent/mod.rs | 4 +- core/tests/correction_amendment.rs | 398 ++++++++++++++++++ ui/components/DiffPanel.tsx | 9 + ui/style/components/DiffPanel.css | 14 + 7 files changed, 451 insertions(+), 9 deletions(-) rename core/src/memory_agent/{ammendment.rs => amendment.rs} (95%) create mode 100644 core/tests/correction_amendment.rs diff --git a/core/Cargo.toml b/core/Cargo.toml index b8d0d15..07f3849 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -19,7 +19,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2.11", default-features = false, features = ["wry"] } +tauri = { version = "2.11", default-features = false, features = ["wry", "test"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/core/src/lib.rs b/core/src/lib.rs index 7da5108..9da07f0 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -607,7 +607,7 @@ pub async fn execute_memory_extraction_pipeline( }) }; - let (id, _amended) = memory_agent::ammendment::amend_or_create_changeset( + let (id, _amended) = memory_agent::amendment::amend_or_create_changeset( &mut conn, &candidates, "default-session", @@ -3620,3 +3620,19 @@ async fn memory_extract_force( result } + +pub async fn test_helper_memory_extract_force( + provider: String, + endpoint: String, + model: String, + db_path: std::path::PathBuf, +) -> Result { + use tauri::Manager; + let app = tauri::test::mock_app(); + app.manage(DbState { + db_path, + redacted_session_key: std::sync::Mutex::new(None), + }); + let state = app.state::(); + memory_extract_force(provider, endpoint, model, state).await +} diff --git a/core/src/memory_agent/ammendment.rs b/core/src/memory_agent/amendment.rs similarity index 95% rename from core/src/memory_agent/ammendment.rs rename to core/src/memory_agent/amendment.rs index a4aa06c..3ba7375 100644 --- a/core/src/memory_agent/ammendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -156,8 +156,7 @@ fn find_pending_changeset( if let Some(row) = rows.next().map_err(|e| format!("Database error: {}", e))? { let id: String = row.get(0).map_err(|e| format!("Database error: {}", e))?; - let content: String = row.get(1).map_err(|e| format!("Database error: {}", e))?; - Ok(Some((id, content))) + Ok(Some((id, String::new()))) } else { Ok(None) } @@ -294,15 +293,21 @@ pub fn amend_or_create_changeset( let proposed_json = serde_json::to_string(&candidate_data) .map_err(|e| format!("JSON serialization error: {}", e))?; + let item_type = match candidate.action { + crate::memory_agent::CandidateAction::Add => "add", + crate::memory_agent::CandidateAction::Update => "update", + crate::memory_agent::CandidateAction::Delete => "delete", + }; + tx.execute( "INSERT INTO changeset_items - (id, changeset_id, proposed_data, reviewed_at, sort_order) - VALUES (?1, ?2, ?3, NULL, + (id, changeset_id, item_type, proposed_data, reviewed_at, sort_order) + VALUES (?1, ?2, ?3, ?4, NULL, COALESCE( (SELECT MAX(sort_order) + 1 FROM changeset_items WHERE changeset_id = ?2), 0 ))", - params![new_item_id, existing_id, proposed_json], + params![new_item_id, existing_id, item_type, proposed_json], ) .map_err(|e| format!("Failed to insert new changeset_item: {}", e))?; diff --git a/core/src/memory_agent/mod.rs b/core/src/memory_agent/mod.rs index 4cff6f3..ea47dc8 100644 --- a/core/src/memory_agent/mod.rs +++ b/core/src/memory_agent/mod.rs @@ -1,4 +1,4 @@ -pub mod ammendment; +pub mod amendment; pub mod changeset; pub mod commit; pub mod correction; @@ -7,7 +7,7 @@ pub mod persistence; pub mod prompt; pub mod similarity; pub mod trigger; -pub use ammendment::amend_or_create_changeset; +pub use amendment::amend_or_create_changeset; pub use changeset::{build_changeset, ChangesetItemType, PendingChangeset, PendingChangesetItem}; pub use commit::commit_changeset_transaction; pub use correction::{detect_correction_signal, has_correction_signal, CorrectionSignal}; diff --git a/core/tests/correction_amendment.rs b/core/tests/correction_amendment.rs new file mode 100644 index 0000000..0b3b3b5 --- /dev/null +++ b/core/tests/correction_amendment.rs @@ -0,0 +1,398 @@ +use std::error::Error; +use std::fs; +use std::path::PathBuf; + +use rusqlite::{params, Connection}; + +use mindvault_lib::memory_agent::{ + amend_or_create_changeset, detect_correction_signal, list_changeset_items, + list_pending_changesets, mark_extraction_complete, persist_changeset, should_extract, + should_extract_correction, CandidateAction, CandidateNode, ChangesetItemType, CorrectionSignal, + PendingChangeset, PendingChangesetItem, +}; + +fn apply_migrations(conn: &Connection) -> Result<(), Box> { + let migrations_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("db") + .join("migrations"); + + let mut paths: Vec = fs::read_dir(&migrations_dir)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "sql")) + .collect(); + + paths.sort(); + + for path in paths { + let sql = fs::read_to_string(&path)?; + conn.execute_batch(&sql)?; + } + Ok(()) +} + +fn setup_test_db() -> Result> { + let conn = Connection::open_in_memory()?; + conn.pragma_update(None, "foreign_keys", "ON")?; + + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + "#, + )?; + + apply_migrations(&conn)?; + + Ok(conn) +} + +fn create_test_session(conn: &Connection, id: &str, vault_id: &str) -> Result<(), Box> { + conn.execute( + "INSERT INTO sessions (id, vault_id) VALUES (?1, ?2);", + params![id, vault_id], + )?; + Ok(()) +} + +fn insert_test_message( + conn: &Connection, + id: &str, + session_id: &str, + role: &str, + content: &str, +) -> Result<(), Box> { + conn.execute( + "INSERT INTO session_messages (id, session_id, role, content) VALUES (?1, ?2, ?3, ?4);", + params![id, session_id, role, content], + )?; + Ok(()) +} + +#[test] +fn test_detect_explicit_correction_phrase() { + let phrases = vec!["actually", "wait,", "i meant"]; + for phrase in phrases { + let msg = format!("{} it should be green", phrase); + let signal = detect_correction_signal(&msg, None, &[]); + assert!( + matches!(signal, Some(CorrectionSignal::ExplicitPhrase { .. })), + "Expected ExplicitPhrase for msg: {}", + msg + ); + } +} + +#[test] +fn test_detect_negation_of_previous_message() { + let previous_message = "My favorite color is blue"; + let current_message = "not blue, it's green"; + let signal = detect_correction_signal(current_message, Some(previous_message), &[]); + assert_eq!( + signal, + Some(CorrectionSignal::Negation { + negated_fragment: "blue".to_string() + }) + ); +} + +#[test] +fn test_detect_changeset_contradiction() -> Result<(), Box> { + let pending_data = vec![r#"{"title": "Blue Theme"}"#.to_string()]; + + // 1. Direct contradiction check without using explicit phrases (which would trigger early return) + let signal = + detect_correction_signal(r#"{"title": "Blue Theme"} is wrong"#, None, &pending_data); + assert_eq!( + signal, + Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: r#"{"title": "Blue Theme"}"#.to_string() + }) + ); + + // 2. Direct contradiction check with "not" + let signal_not = + detect_correction_signal(r#"not {"title": "Blue Theme"}"#, None, &pending_data); + assert_eq!( + signal_not, + Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: r#"{"title": "Blue Theme"}"#.to_string() + }) + ); + + // 3. User requested feed message: "actually it should be green theme" + // Note: This message contains "actually" which triggers the ExplicitPhrase scan early. + let signal_actually = + detect_correction_signal("actually it should be green theme", None, &pending_data); + assert_eq!( + signal_actually, + Some(CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string() + }) + ); + + Ok(()) +} + +#[test] +fn test_no_false_positive_on_neutral_message() { + let signal = detect_correction_signal("tell me about rust", None, &[]); + assert!(signal.is_none()); +} + +#[test] +fn test_should_extract_correction_bypasses_debounce() -> Result<(), Box> { + let conn = setup_test_db()?; + let session_id = "default-session"; + create_test_session(&conn, session_id, "vault_learning")?; + + // Insert 3 messages (minimum threshold for correction check) + insert_test_message(&conn, "msg_1", session_id, "user", "Hello")?; + insert_test_message(&conn, "msg_2", session_id, "assistant", "Hi")?; + insert_test_message(&conn, "msg_3", session_id, "user", "Let's study Rust")?; + + // Mark extraction complete at 3 messages (this starts the debounce) + mark_extraction_complete(&conn, 3)?; + + // should_extract must return false because debounce is active + let ready = should_extract(&conn, session_id)?; + assert!(!ready); + + // should_extract_correction must return true (correction message bypasses debounce) + let ready_correction = should_extract_correction(&conn, session_id, "actually I meant Go")?; + assert!(ready_correction); + + Ok(()) +} + +#[test] +fn test_amend_existing_changeset_in_place() -> Result<(), Box> { + let mut conn = setup_test_db()?; + let session_id = "test-session"; + let model = "granite4.1:3b"; + + // Create pending changeset with one item + let pending_items = vec![PendingChangesetItem { + item_type: ChangesetItemType::Add, + target_node_id: None, + proposed_data: + r#"{"title":"Blue Theme","summary":"This is a beautiful blue theme summary"}"# + .to_string(), + existing_data: None, + similarity: None, + merge_with_id: None, + }]; + let pending_changeset = PendingChangeset { + session_id: session_id.to_string(), + model_used: Some(model.to_string()), + items: pending_items, + }; + + let tx = conn.transaction()?; + let cs_id = persist_changeset(&tx, &pending_changeset, Some(model))?; + tx.commit()?; + + // Get the original item ID + let items_before = list_changeset_items(&conn, &cs_id)?; + assert_eq!(items_before.len(), 1); + let original_item_id = items_before[0].id.clone(); + + // Corrected candidate with similarity > 0.5 (Jaccard = 6/8 = 0.75) + let candidates = vec![CandidateNode { + title: "Green Theme".to_string(), + summary: "This is a beautiful green theme summary".to_string(), + detail: None, + node_type: Some("concept".to_string()), + target_vault_key: Some("personal".to_string()), + tags: None, + confidence: 0.95, + action: CandidateAction::Add, + }]; + + let correction_signal = CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string(), + }; + + let (returned_cs_id, amended) = amend_or_create_changeset( + &mut conn, + &candidates, + session_id, + model, + &correction_signal, + )?; + + // Assert original changeset_items row was updated in-place, not duplicated + assert_eq!(returned_cs_id, cs_id); + assert!(amended); + + let items_after = list_changeset_items(&conn, &cs_id)?; + assert_eq!(items_after.len(), 1); + assert_eq!(items_after[0].id, original_item_id); + + // Verify _amended metadata is present in proposed_data + let parsed_data: serde_json::Value = serde_json::from_str(&items_after[0].proposed_data)?; + assert!(parsed_data.get("_amended").is_some()); + let amended_meta = parsed_data.get("_amended").unwrap(); + assert!(amended_meta.get("similarity").is_some()); + assert!(amended_meta.get("reason").is_some()); + + Ok(()) +} + +#[test] +fn test_amend_creates_new_when_no_pending_exists() -> Result<(), Box> { + let mut conn = setup_test_db()?; + let session_id = "test-session"; + let model = "granite4.1:3b"; + + let candidates = vec![CandidateNode { + title: "Green Theme".to_string(), + summary: "This is a beautiful green theme summary".to_string(), + detail: None, + node_type: Some("concept".to_string()), + target_vault_key: Some("personal".to_string()), + tags: None, + confidence: 0.95, + action: CandidateAction::Add, + }]; + let correction_signal = CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string(), + }; + + let (cs_id, amended) = amend_or_create_changeset( + &mut conn, + &candidates, + session_id, + model, + &correction_signal, + )?; + + // Assert a new changeset is created normally + assert!(!amended); + + let pending_changesets = list_pending_changesets(&conn)?; + assert_eq!(pending_changesets.len(), 1); + assert_eq!(pending_changesets[0].id, cs_id); + + Ok(()) +} + +#[test] +fn test_amend_appends_genuinely_new_candidate() -> Result<(), Box> { + let mut conn = setup_test_db()?; + let session_id = "test-session"; + let model = "granite4.1:3b"; + + // Create pending changeset with item A + let pending_items = vec![PendingChangesetItem { + item_type: ChangesetItemType::Add, + target_node_id: None, + proposed_data: + r#"{"title":"Blue Theme","summary":"This is a beautiful blue theme summary"}"# + .to_string(), + existing_data: None, + similarity: None, + merge_with_id: None, + }]; + let pending_changeset = PendingChangeset { + session_id: session_id.to_string(), + model_used: Some(model.to_string()), + items: pending_items, + }; + + let tx = conn.transaction()?; + let cs_id = persist_changeset(&tx, &pending_changeset, Some(model))?; + tx.commit()?; + + // Unrelated candidate B (similarity = 0.0) + let candidates = vec![CandidateNode { + title: "Baking Cakes".to_string(), + summary: "How to bake chocolate cakes".to_string(), + detail: None, + node_type: Some("concept".to_string()), + target_vault_key: Some("learning".to_string()), + tags: None, + confidence: 0.9, + action: CandidateAction::Add, + }]; + + let correction_signal = CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string(), + }; + + let (returned_cs_id, amended) = amend_or_create_changeset( + &mut conn, + &candidates, + session_id, + model, + &correction_signal, + )?; + + // Assert B is inserted as a new row and item_count is incremented + assert_eq!(returned_cs_id, cs_id); + assert!(amended); + + let items = list_changeset_items(&conn, &cs_id)?; + assert_eq!(items.len(), 2); + + let count: i64 = conn.query_row( + "SELECT item_count FROM changesets WHERE id = ?1;", + params![cs_id], + |row| row.get(0), + )?; + assert_eq!(count, 2); + + Ok(()) +} + +#[test] +fn test_force_extract_minimum_message_threshold() -> Result<(), Box> { + tauri::async_runtime::block_on(async { + let temp_dir = std::env::temp_dir(); + let db_path = temp_dir.join("test_force_extract.db"); + if db_path.exists() { + let _ = std::fs::remove_file(&db_path); + } + + let session_id = "default-session"; + { + let conn = rusqlite::Connection::open(&db_path)?; + conn.pragma_update(None, "foreign_keys", "ON")?; + apply_migrations(&conn)?; + create_test_session(&conn, session_id, "vault_learning")?; + + // Insert fewer than 3 messages (e.g. 2 messages) + insert_test_message(&conn, "msg_1", session_id, "user", "Hello")?; + insert_test_message(&conn, "msg_2", session_id, "assistant", "Hi")?; + } + + // Call memory_extract_force via test_helper_memory_extract_force + let result = mindvault_lib::test_helper_memory_extract_force( + "ollama".to_string(), + "http://localhost:11434".to_string(), + "granite".to_string(), + db_path.clone(), + ) + .await; + + // Assert it returns an error + assert!(result.is_err()); + let err_msg = result.err().unwrap(); + assert!( + err_msg.contains("at least 3 messages"), + "Expected 'at least 3 messages' error, got: {}", + err_msg + ); + + // Clean up + if db_path.exists() { + let _ = std::fs::remove_file(&db_path); + } + + Ok(()) + }) +} diff --git a/ui/components/DiffPanel.tsx b/ui/components/DiffPanel.tsx index a2e6d55..7da4472 100644 --- a/ui/components/DiffPanel.tsx +++ b/ui/components/DiffPanel.tsx @@ -40,6 +40,8 @@ export default function DiffPanel({ const [error, setError] = useState(null); const [isClosing, setIsClosing] = useState(false); const [refreshTrigger, setRefreshTrigger] = useState(0); + const [isAmended, setIsAmended] = useState(false); + const [amendedAt, setAmendedAt] = useState(null); const [drawerWidth, setDrawerWidth] = useState(() => { try { const saved = localStorage.getItem("mindvault-diff-panel-width"); @@ -301,6 +303,8 @@ export default function DiffPanel({ const parsed = parseJSON(item.proposedData); const summary = (parsed.summary || "").toLowerCase(); const detail = (parsed.detail || "").toLowerCase(); + setIsAmended(parsed._amended != null); + setAmendedAt(isAmended ? parsed._amended.at : null); const matchSearch = title.includes(searchQuery.toLowerCase()) || summary.includes(searchQuery.toLowerCase()) || @@ -599,6 +603,11 @@ export default function DiffPanel({ }} > + {isAmended && ( + + (amended) + + )}
))} diff --git a/ui/style/components/DiffPanel.css b/ui/style/components/DiffPanel.css index 3b69bab..233b6a0 100644 --- a/ui/style/components/DiffPanel.css +++ b/ui/style/components/DiffPanel.css @@ -669,3 +669,17 @@ color: #991b1b; line-height: 1.45; } + +.diff-amended-badge { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 500; + color: rgba(251, 191, 36, 0.9); + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.2); + border-radius: 6px; + padding: 1px 6px; + margin-left: 6px; + letter-spacing: 0.02em; +} From 4c86912331c6d30f85bcd16439bf7a8a8f55f85e Mon Sep 17 00:00:00 2001 From: Evan Fioritto Date: Sun, 14 Jun 2026 14:21:52 -0400 Subject: [PATCH 08/59] Ui changes --- ui/App.tsx | 262 +++++++++++++++-------- ui/components/ChatHistoryPanel.tsx | 8 + ui/components/ChatPanel.tsx | 8 - ui/components/DiffPanel.tsx | 46 ++-- ui/ipc.ts | 6 +- ui/style/App.css | 114 +++++----- ui/style/MonoStyles.css | 1 + ui/style/components/ChatHistoryPanel.css | 86 ++++++++ 8 files changed, 339 insertions(+), 192 deletions(-) create mode 100644 ui/components/ChatHistoryPanel.tsx create mode 100644 ui/style/components/ChatHistoryPanel.css diff --git a/ui/App.tsx b/ui/App.tsx index e9f7b43..277a824 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -24,6 +24,11 @@ import DiffPanel from "./components/DiffPanel"; import styles from "./style/components/MemoryBadge.module.css"; import { countPendingChangesetItems } from "./services/memoryAgent"; import "./style/MonoStyles.css"; +import ChatHistoryPanel from "./components/ChatHistoryPanel"; + +const SIDEBAR_RAIL_WIDTH = 48; + +type RightPanelView = "notes" | "dashboard" | "settings"; function App() { const [onboardingResolved, setOnboardingResolved] = useState(false); @@ -33,6 +38,7 @@ function App() { const [pendingProposalCount, setPendingProposalCount] = useState(0); const [isDiffPanelOpen, setIsDiffPanelOpen] = useState(false); const [selectedChangesetId, setSelectedChangesetId] = useState(null); + const [leftPanelView, setLeftPanelView] = useState<"browse" | "history">("browse"); useEffect(() => { let active = true; @@ -175,8 +181,7 @@ function App() { const [nodeRefreshKey, setNodeRefreshKey] = useState(0); const [isRedactedUnlocked, setIsRedactedUnlocked] = useState(false); const [selectedVaultRequiresUnlock, setSelectedVaultRequiresUnlock] = useState(false); - const [showDashboard, setShowDashboard] = useState(false); - const [showSettings, setShowSettings] = useState(false); + const [rightPanelView, setRightPanelView] = useState("notes"); const [sidebarModalOpen, setSidebarModalOpen] = useState(false); const [editorModalOpen, setEditorModalOpen] = useState(false); const [spatialModalOpen, setSpatialModalOpen] = useState(false); @@ -190,7 +195,8 @@ function App() { } setViewMode(newMode); }; - const leftPaneExpanded = leftPanePinned && !selectedVaultRequiresUnlock; + const leftPaneExpanded = + leftPanePinned && (leftPanelView !== "browse" || !selectedVaultRequiresUnlock); const rightPaneExpanded = rightPanePinned; const scopeNodeIds = useMemo(() => (selectedNodeId ? [selectedNodeId] : []), [selectedNodeId]); const [assemblerScope, setAssemblerScope] = useState("local"); @@ -321,6 +327,27 @@ function App() { setRightResizing(true); }; + function onLeftRailToggle(view: "browse" | "history") { + if (view === "browse" && selectedVaultRequiresUnlock) { + return; + } + if (leftPanePinned && leftPanelView === view) { + setLeftPanePinned(false); + return; + } + setLeftPanelView(view); + setLeftPanePinned(true); + } + + function onRightRailToggle(view: RightPanelView) { + if (rightPanePinned && rightPanelView === view) { + setRightPanePinned(false); + return; + } + setRightPanelView(view); + setRightPanePinned(true); + } + function closeAllPanes() { // The left pane is meant to be persistently pinned in spatial view, // so clicking the canvas layout does not clear leftPanePinned. @@ -336,8 +363,8 @@ function App() { function onSelectVault(vaultId: string | null) { setSelectedVaultId(vaultId); setSelectedNodeId(null); - setShowDashboard(false); - setShowSettings(false); + setRightPanelView("notes"); + setLeftPanelView("browse"); setLeftPanePinned(Boolean(vaultId)); setNodeRefreshKey((value) => value + 1); } @@ -345,8 +372,8 @@ function App() { function onFocusVault(vaultId: string | null) { setSelectedVaultId(vaultId); setSelectedNodeId(null); - setShowDashboard(false); - setShowSettings(false); + setRightPanelView("notes"); + setLeftPanelView("browse"); setNodeRefreshKey((value) => value + 1); } @@ -371,15 +398,13 @@ function App() { function onSelectNode(nodeId: string) { setSelectedNodeId(nodeId); - setShowDashboard(false); - setShowSettings(false); + setRightPanelView("notes"); setRightPanePinned(true); } function onNodeCreated(nodeId: string) { setSelectedNodeId(nodeId); - setShowDashboard(false); - setShowSettings(false); + setRightPanelView("notes"); setRightPanePinned(true); setNodeRefreshKey((value) => value + 1); } @@ -398,15 +423,13 @@ function App() { function onOpenDashboard() { setSelectedNodeId(null); - setShowDashboard(true); - setShowSettings(false); + setRightPanelView("dashboard"); setRightPanePinned(true); } function onOpenSettings() { setSelectedNodeId(null); - setShowDashboard(false); - setShowSettings(true); + setRightPanelView("settings"); setRightPanePinned(true); } @@ -429,24 +452,15 @@ function App() { } } - const leftToggleStyle = { - left: leftPaneExpanded || sidebarModalOpen ? `${leftPaneWidth + 16}px` : "16px", - zIndex: 1005, - }; - - const rightToggleStyle = { - right: rightPaneExpanded ? `${rightPaneWidth + 48}px` : "48px", - zIndex: 1005, - }; - const zenCanvasStyle = { left: viewMode === "editor" ? "0px" - : leftPaneExpanded || sidebarModalOpen - ? `${leftPaneWidth}px` - : "0px", - right: viewMode === "editor" ? "0px" : rightPaneExpanded ? `${rightPaneWidth}px` : "0px", + : `${SIDEBAR_RAIL_WIDTH + (leftPaneExpanded || sidebarModalOpen ? leftPaneWidth : 0)}px`, + right: + viewMode === "editor" + ? "0px" + : `${SIDEBAR_RAIL_WIDTH + (rightPaneExpanded ? rightPaneWidth : 0)}px`, }; return ( @@ -595,7 +609,9 @@ function App() { className={`pane-wrap left ${leftPaneExpanded || sidebarModalOpen ? "show" : ""}`} style={{ width: `${leftPaneWidth}px` }} > - {!selectedVaultId ? ( + {leftPanelView === "history" ? ( + + ) : !selectedVaultId ? ( - {showDashboard ? ( + {rightPanelView === "dashboard" ? ( - ) : showSettings ? ( + ) : rightPanelView === "settings" ? ( ) : (
@@ -678,76 +694,132 @@ function App() {
)} - {/* Left Sidebar Toggle Button */} + {/* Left Sidebar Icon Rail */} + {/* Left Sidebar Icon Rail */} {viewMode !== "editor" && ( - + + + + + + + )} - {/* Right Sidebar Toggle Button */} + {/* Right Sidebar Icon Rail */} {viewMode !== "editor" && ( - + + + + + + + + )} {isDiffPanelOpen && ( diff --git a/ui/components/ChatHistoryPanel.tsx b/ui/components/ChatHistoryPanel.tsx new file mode 100644 index 0000000..6d25536 --- /dev/null +++ b/ui/components/ChatHistoryPanel.tsx @@ -0,0 +1,8 @@ +export default function ChatHistoryPanel() { + return ( +
+

Chat History

+

This is where the chat history would be displayed.

+
+ ); +} diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 7fdaf2e..0f141ba 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -855,14 +855,6 @@ function ChatPanel({ > ➔ -
diff --git a/ui/components/DiffPanel.tsx b/ui/components/DiffPanel.tsx index 7da4472..f94cb7c 100644 --- a/ui/components/DiffPanel.tsx +++ b/ui/components/DiffPanel.tsx @@ -40,8 +40,6 @@ export default function DiffPanel({ const [error, setError] = useState(null); const [isClosing, setIsClosing] = useState(false); const [refreshTrigger, setRefreshTrigger] = useState(0); - const [isAmended, setIsAmended] = useState(false); - const [amendedAt, setAmendedAt] = useState(null); const [drawerWidth, setDrawerWidth] = useState(() => { try { const saved = localStorage.getItem("mindvault-diff-panel-width"); @@ -303,8 +301,6 @@ export default function DiffPanel({ const parsed = parseJSON(item.proposedData); const summary = (parsed.summary || "").toLowerCase(); const detail = (parsed.detail || "").toLowerCase(); - setIsAmended(parsed._amended != null); - setAmendedAt(isAmended ? parsed._amended.at : null); const matchSearch = title.includes(searchQuery.toLowerCase()) || summary.includes(searchQuery.toLowerCase()) || @@ -593,23 +589,31 @@ export default function DiffPanel({
{/* Detailed diff cards will render here in Commit 4. For Commit 3, we render a highly polished list summary with type badges. */} - {filteredItems.map((item) => ( -
- - {isAmended && ( - - (amended) - - )} -
- ))} + {filteredItems.map((item) => { + const parsedData = parseJSON(item.proposedData); + const itemIsAmended = parsedData._amended != null; + const itemAmendedAt = itemIsAmended ? parsedData._amended.at : null; + return ( +
+ + {itemIsAmended && ( + + (amended) + + )} +
+ ); + })}
)} diff --git a/ui/ipc.ts b/ui/ipc.ts index e126f1b..92f8899 100644 --- a/ui/ipc.ts +++ b/ui/ipc.ts @@ -340,9 +340,11 @@ export function memoryExtractForce( endpoint: string, model: string ): Promise> { - return invoke>("memory_extract_force", { + return invoke("memory_extract_force", { provider, endpoint, model, - }); + }) + .then((ok) => ({ ok }) as IpcResult) + .catch((error) => ({ err: String(error) }) as IpcResult); } diff --git a/ui/style/App.css b/ui/style/App.css index 34cd016..4b84d10 100644 --- a/ui/style/App.css +++ b/ui/style/App.css @@ -39,92 +39,74 @@ right 220ms ease; } -/* Pinned layout coordinate shifts (Handled dynamically via React inline styles in App.tsx) */ -/* .hybrid-shell.left-pinned .zen-canvas { - left: 280px; -} */ - -/* .hybrid-shell.right-pinned .zen-canvas { - right: min(560px, 52vw); -} */ - -/* @media (max-width: 1100px) { - .hybrid-shell.left-pinned .zen-canvas { - left: 260px; - } -} */ - -/* Exquisite floating sidebar toggle button handles */ -.sidebar-toggle-btn { +/* Persistent icon rails for left/right panel views */ +.icon-rail { position: absolute; - top: 16px; + top: 0; + bottom: 0; + width: 48px; z-index: 1005; - width: 36px; - height: 36px; display: flex; + flex-direction: column; align-items: center; - justify-content: center; - border-radius: 8px; + gap: 8px; + padding: 16px 0; background: rgba(250, 249, 247, 0.85); - border: 1px solid rgba(188, 108, 37, 0.18); - color: var(--primary-color); - cursor: pointer; backdrop-filter: blur(8px); - transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 2px 8px rgba(188, 108, 37, 0.06); } -.hybrid-shell.modal-open .sidebar-toggle-btn, +.icon-rail-left { + left: 0; + border-right: 1px solid rgba(188, 108, 37, 0.18); +} + +.icon-rail-right { + right: 0; + border-left: 1px solid rgba(188, 108, 37, 0.18); +} + +.hybrid-shell.modal-open .icon-rail, .hybrid-shell.modal-open .canvas-view-toggle-pill, -.hybrid-shell:has(.charts-modal-overlay) .sidebar-toggle-btn, +.hybrid-shell:has(.charts-modal-overlay) .icon-rail, .hybrid-shell:has(.charts-modal-overlay) .canvas-view-toggle-pill { display: none !important; } -.sidebar-toggle-btn:hover { - background: rgba(255, 255, 255, 1); - border-color: var(--primary-color); +.icon-rail-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: transparent; + border: 1px solid transparent; color: var(--primary-color); - transform: scale(1.05); - box-shadow: 0 4px 12px rgba(188, 108, 37, 0.12); -} - -.sidebar-toggle-btn:disabled { - opacity: 0.35; - cursor: not-allowed; - transform: none; - box-shadow: 0 2px 8px rgba(188, 108, 37, 0.03); + cursor: pointer; + transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1); } -.sidebar-toggle-btn:disabled:hover { - background: rgba(250, 249, 247, 0.85); - border-color: rgba(188, 108, 37, 0.18); +.icon-rail-btn:hover { + background: rgba(188, 108, 37, 0.1); + border-color: rgba(188, 108, 37, 0.2); color: var(--primary-color); - transform: none; - box-shadow: 0 2px 8px rgba(188, 108, 37, 0.03); } -.sidebar-toggle-btn.left { - left: 16px; +.icon-rail-btn.active { + background: rgba(188, 108, 37, 0.18); + border-color: rgba(188, 108, 37, 0.35); + box-shadow: 0 2px 8px rgba(188, 108, 37, 0.12); } -/* .sidebar-toggle-btn.left.open { - left: 296px; -} */ - -.sidebar-toggle-btn.right { - right: 48px; +.icon-rail-btn:disabled { + opacity: 0.35; + cursor: not-allowed; } -/* .sidebar-toggle-btn.right.open { - right: calc(min(560px, 52vw) + 16px); -} */ - -/* @media (max-width: 1100px) { - .sidebar-toggle-btn.left.open { - left: 276px; - } -} */ +.icon-rail-btn:disabled:hover { + background: transparent; + border-color: transparent; +} /* Resizing handles and drag override states */ .hybrid-shell.is-resizing { @@ -134,7 +116,7 @@ .hybrid-shell.is-resizing .pane-wrap, .hybrid-shell.is-resizing .zen-canvas, -.hybrid-shell.is-resizing .sidebar-toggle-btn { +.hybrid-shell.is-resizing .icon-rail { transition: none !important; } @@ -189,7 +171,7 @@ } .pane-wrap.left { - left: 0; + left: 48px; width: 280px; transform: translateX(-100%); } @@ -199,7 +181,7 @@ } .pane-wrap.right { - right: 0; + right: 48px; width: min(560px, 52vw); transform: translateX(100%); } diff --git a/ui/style/MonoStyles.css b/ui/style/MonoStyles.css index c51af83..1286671 100644 --- a/ui/style/MonoStyles.css +++ b/ui/style/MonoStyles.css @@ -14,3 +14,4 @@ @import "./components/ScopeIndicator.css"; @import "./components/VaultSidebar.css"; @import "./components/LatexBlock.css"; +@import "./components/ChatHistoryPanel.css"; diff --git a/ui/style/components/ChatHistoryPanel.css b/ui/style/components/ChatHistoryPanel.css new file mode 100644 index 0000000..a7a61e7 --- /dev/null +++ b/ui/style/components/ChatHistoryPanel.css @@ -0,0 +1,86 @@ +.chat-history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.chat-history-item-btn { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 2px; + border: 1px solid rgba(188, 108, 37, 0.12); + background: rgba(188, 108, 37, 0.04); + border-radius: 8px; + padding: 8px 10px; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease; +} + +.chat-history-item-btn:hover { + background: rgba(188, 108, 37, 0.1); + border-color: rgba(188, 108, 37, 0.25); +} + +.chat-history-item-btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.chat-history-role { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--primary-color); + opacity: 0.8; +} + +.chat-history-preview { + font-size: 0.85rem; + color: var(--text-black); + line-height: 1.35; +} + +.chat-history-item.role-assistant .chat-history-item-btn { + background: rgba(0, 0, 0, 0.02); +} + +.chat-history-edit { + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid rgba(188, 108, 37, 0.25); + border-radius: 8px; + padding: 8px 10px; + background: rgba(188, 108, 37, 0.06); +} + +.chat-history-edit textarea { + width: 100%; + resize: vertical; + font-family: inherit; + font-size: 0.85rem; + border-radius: 6px; + border: 1px solid rgba(188, 108, 37, 0.2); + padding: 6px 8px; + background: rgba(255, 255, 255, 0.7); + color: var(--text-black); +} + +.chat-history-edit-actions { + display: flex; + gap: 8px; +} + +.chat-history-edit-hint { + margin: 0; + font-size: 0.75rem; + color: rgba(27, 26, 23, 0.6); +} From 8e3781eff4bc523f4672984da236f76f068dff01 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Sun, 14 Jun 2026 23:08:40 -0400 Subject: [PATCH 09/59] implement correction signal detection for memory agent and add corresponding test suite --- core/src/memory_agent/amendment.rs | 5 +- core/src/memory_agent/correction.rs | 78 +++++++++++++--- core/src/memory_agent/trigger.rs | 92 +++++++++---------- core/tests/correction_amendment.rs | 16 ++-- ui/types/generated/Backlink.ts | 8 +- ui/types/generated/Changeset.ts | 13 ++- ui/types/generated/ChangesetCommitInput.ts | 2 +- ui/types/generated/ChangesetItem.ts | 17 +++- ui/types/generated/Door.ts | 13 ++- ui/types/generated/DoorCreateInput.ts | 7 +- ui/types/generated/ItemReviewAction.ts | 2 +- ui/types/generated/Node.ts | 21 ++++- ui/types/generated/NodeCreateInput.ts | 14 ++- ui/types/generated/NodeUpdateInput.ts | 16 +++- .../generated/OnboardingNodeCommitInput.ts | 10 +- ui/types/generated/OnboardingProposedNode.ts | 11 ++- ui/types/generated/Tag.ts | 2 +- ui/types/generated/TagCreateInput.ts | 2 +- ui/types/generated/Vault.ts | 17 +++- ui/types/generated/VaultCreateInput.ts | 11 ++- ui/types/generated/VaultUpdateInput.ts | 9 +- 21 files changed, 280 insertions(+), 86 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 3ba7375..01d8082 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -63,7 +63,7 @@ fn chrono_now_iso() -> String { } fn is_leap(year: u64) -> bool { - (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) } /// Computes Jaccard similarity between two strings at the whitespace-token level. @@ -236,7 +236,8 @@ pub fn amend_or_create_changeset( } // ── 2b. Pending changeset exists — amend in-place where possible ───────── - let (existing_id, _content) = existing_changeset.unwrap(); + let (existing_id, _content) = + existing_changeset.ok_or_else(|| "Pending changeset unexpectedly missing".to_string())?; let tx = conn .transaction() diff --git a/core/src/memory_agent/correction.rs b/core/src/memory_agent/correction.rs index 6bdb601..050a29b 100644 --- a/core/src/memory_agent/correction.rs +++ b/core/src/memory_agent/correction.rs @@ -1,4 +1,42 @@ -/// This module defines the logic for detecting correction signals in user messages. +//! This module defines the logic for detecting correction signals in user messages. + +fn contains_phrase_with_boundaries(message: &str, phrase: &str) -> bool { + let msg_len = message.len(); + let phrase_len = phrase.len(); + if phrase_len == 0 { + return true; + } + + let mut start = 0; + while let Some(pos) = message[start..].find(phrase) { + let abs_pos = start + pos; + let end_pos = abs_pos + phrase_len; + + let before_ok = if abs_pos == 0 { + true + } else { + message[..abs_pos] + .chars() + .next_back() + .is_none_or(|c| !c.is_alphanumeric()) + }; + + let after_ok = if end_pos >= msg_len { + true + } else { + message[end_pos..] + .chars() + .next() + .is_none_or(|c| !c.is_alphanumeric()) + }; + + if before_ok && after_ok { + return true; + } + start = abs_pos + 1; + } + false +} /// Evaluates whether a user message contains correction signals. /// Returns `Some(CorrectionSignal)` if detected, `None` otherwise. @@ -11,7 +49,7 @@ pub fn detect_correction_signal( // 1. Explicit Phrase Scan for phrase in CORRECTION_PHRASES { - if message_lower.contains(phrase) { + if contains_phrase_with_boundaries(&message_lower, phrase) { return Some(CorrectionSignal::ExplicitPhrase { phrase: phrase.to_string(), }); @@ -19,8 +57,8 @@ pub fn detect_correction_signal( } // 2. Direct Negation Scan - if previous_message.is_some() { - let prev_lower = previous_message.unwrap().to_lowercase(); + if let Some(prev) = previous_message { + let prev_lower = prev.to_lowercase(); for word in prev_lower.split_whitespace() { // Check if current message negates a specific word/phrase from the previous message if message_lower.contains(&format!("not {}", word)) @@ -35,15 +73,29 @@ pub fn detect_correction_signal( } } - // Check for contradictions with pending proposed data - for pending in pending_proposed_data { - // Check if current message contradicts a pending proposed field (e.g., "not X", "X is wrong") - if message_lower.contains(&format!("not {}", pending.to_lowercase())) - || message_lower.contains(&format!("{} is wrong", pending.to_lowercase())) - { - return Some(CorrectionSignal::ChangesetContradiction { - contradicted_field: pending.clone(), - }); + // Check for contradictions with pending proposed data (title and summary only) + for pending_raw in pending_proposed_data { + if let Ok(val) = serde_json::from_str::(pending_raw) { + if let Some(title) = val.get("title").and_then(|t| t.as_str()) { + let title_lower = title.to_lowercase(); + if message_lower.contains(&format!("not {}", title_lower)) + || message_lower.contains(&format!("{} is wrong", title_lower)) + { + return Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: title.to_string(), + }); + } + } + if let Some(summary) = val.get("summary").and_then(|s| s.as_str()) { + let summary_lower = summary.to_lowercase(); + if message_lower.contains(&format!("not {}", summary_lower)) + || message_lower.contains(&format!("{} is wrong", summary_lower)) + { + return Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: summary.to_string(), + }); + } + } } } diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index a6ea5d9..f848647 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -113,6 +113,52 @@ pub fn mark_extraction_complete( Ok(()) } +/// Evaluates whether a correction signal should bypass the standard debounce gate. +/// Returns true if a correction was detected AND there are at least 3 messages +/// in the session (minimum viable context for extraction). +pub fn should_extract_correction( + conn: &Connection, + session_id: &str, + message: &str, +) -> Result { + // 1. Query latest user message prior to this one in session + let previous_message: Option = conn + .query_row( + "SELECT content FROM session_messages WHERE session_id = ?1 ORDER BY created_at DESC LIMIT 1;", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("Failed querying latest message: {err}"))?; + + // 2. Query all pending changeset_items with status 'pending' and extract their proposed_data column values + let pending_data: Vec = conn + .prepare("SELECT proposed_data FROM changeset_items WHERE status = 'pending';") + .map_err(|err| format!("Failed preparing pending changeset query: {err}"))? + .query_map([], |row| row.get(0)) + .map_err(|err| format!("Failed querying pending changeset items: {err}"))? + .collect::, _>>() + .map_err(|err| format!("Failed reading pending changeset row: {err}"))?; + + let signal: bool = + correction::has_correction_signal(message, previous_message.as_deref(), &pending_data); + + // 3. Check for signal detection and message count threshold (3) + let message_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_messages WHERE session_id = ?1;", + [session_id], + |row| row.get(0), + ) + .map_err(|err| format!("Failed querying session message count: {err}"))?; + + if signal && message_count >= 3 { + Ok(true) + } else { + Ok(false) + } +} + #[cfg(test)] mod tests { use super::*; @@ -255,49 +301,3 @@ mod tests { Ok(()) } } - -/// Evaluates whether a correction signal should bypass the standard debounce gate. -/// Returns true if a correction was detected AND there are at least 3 messages -/// in the session (minimum viable context for extraction). -pub fn should_extract_correction( - conn: &Connection, - session_id: &str, - message: &str, -) -> Result { - // 1. Query latest user message prior to this one in session - let previous_message: Option = conn - .query_row( - "SELECT content FROM session_messages WHERE session_id = ?1 ORDER BY created_at DESC LIMIT 1;", - [session_id], - |row| row.get(0), - ) - .optional() - .map_err(|err| format!("Failed querying latest message: {err}"))?; - - // 2. Query all pending changeset_items with status 'pending' and extract their proposed_data column values - let pending_data: Vec = conn - .prepare("SELECT proposed_data FROM changeset_items WHERE status = 'pending';") - .map_err(|err| format!("Failed preparing pending changeset query: {err}"))? - .query_map([], |row| row.get(0)) - .map_err(|err| format!("Failed querying pending changeset items: {err}"))? - .collect::, _>>() - .map_err(|err| format!("Failed reading pending changeset row: {err}"))?; - - let signal: bool = - correction::has_correction_signal(message, previous_message.as_deref(), &pending_data); - - // 3. Check for signal detection and message count threshold (3) - let message_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM session_messages WHERE session_id = ?1;", - [session_id], - |row| row.get(0), - ) - .map_err(|err| format!("Failed querying session message count: {err}"))?; - - if signal && message_count >= 3 { - Ok(true) - } else { - Ok(false) - } -} diff --git a/core/tests/correction_amendment.rs b/core/tests/correction_amendment.rs index 0b3b3b5..b980a6b 100644 --- a/core/tests/correction_amendment.rs +++ b/core/tests/correction_amendment.rs @@ -105,22 +105,20 @@ fn test_detect_changeset_contradiction() -> Result<(), Box> { let pending_data = vec![r#"{"title": "Blue Theme"}"#.to_string()]; // 1. Direct contradiction check without using explicit phrases (which would trigger early return) - let signal = - detect_correction_signal(r#"{"title": "Blue Theme"} is wrong"#, None, &pending_data); + let signal = detect_correction_signal("Blue Theme is wrong", None, &pending_data); assert_eq!( signal, Some(CorrectionSignal::ChangesetContradiction { - contradicted_field: r#"{"title": "Blue Theme"}"#.to_string() + contradicted_field: "Blue Theme".to_string() }) ); // 2. Direct contradiction check with "not" - let signal_not = - detect_correction_signal(r#"not {"title": "Blue Theme"}"#, None, &pending_data); + let signal_not = detect_correction_signal("not Blue Theme", None, &pending_data); assert_eq!( signal_not, Some(CorrectionSignal::ChangesetContradiction { - contradicted_field: r#"{"title": "Blue Theme"}"#.to_string() + contradicted_field: "Blue Theme".to_string() }) ); @@ -236,7 +234,9 @@ fn test_amend_existing_changeset_in_place() -> Result<(), Box> { // Verify _amended metadata is present in proposed_data let parsed_data: serde_json::Value = serde_json::from_str(&items_after[0].proposed_data)?; assert!(parsed_data.get("_amended").is_some()); - let amended_meta = parsed_data.get("_amended").unwrap(); + let amended_meta = parsed_data + .get("_amended") + .ok_or("expected _amended metadata")?; assert!(amended_meta.get("similarity").is_some()); assert!(amended_meta.get("reason").is_some()); @@ -381,7 +381,7 @@ fn test_force_extract_minimum_message_threshold() -> Result<(), Box> // Assert it returns an error assert!(result.is_err()); - let err_msg = result.err().unwrap(); + let err_msg = result.err().ok_or("expected error result")?; assert!( err_msg.contains("at least 3 messages"), "Expected 'at least 3 messages' error, got: {}", diff --git a/ui/types/generated/Backlink.ts b/ui/types/generated/Backlink.ts index 0cd2e51..807cfdc 100644 --- a/ui/types/generated/Backlink.ts +++ b/ui/types/generated/Backlink.ts @@ -1,3 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Backlink = { id: string, targetNodeId: string, sourceNodeId: string, doorId: string, createdAt: string, }; +export type Backlink = { + id: string; + targetNodeId: string; + sourceNodeId: string; + doorId: string; + createdAt: string; +}; diff --git a/ui/types/generated/Changeset.ts b/ui/types/generated/Changeset.ts index e291692..f38039d 100644 --- a/ui/types/generated/Changeset.ts +++ b/ui/types/generated/Changeset.ts @@ -1,3 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Changeset = { id: string, sessionId: string | null, status: string, itemCount: number, acceptedCount: number, dismissedCount: number, modelUsed: string | null, createdAt: string, reviewedAt: string | null, summary: string | null, }; +export type Changeset = { + id: string; + sessionId: string | null; + status: string; + itemCount: number; + acceptedCount: number; + dismissedCount: number; + modelUsed: string | null; + createdAt: string; + reviewedAt: string | null; + summary: string | null; +}; diff --git a/ui/types/generated/ChangesetCommitInput.ts b/ui/types/generated/ChangesetCommitInput.ts index ddbc530..d4d5bde 100644 --- a/ui/types/generated/ChangesetCommitInput.ts +++ b/ui/types/generated/ChangesetCommitInput.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ItemReviewAction } from "./ItemReviewAction"; -export type ChangesetCommitInput = { changesetId: string, itemActions: Array, }; +export type ChangesetCommitInput = { changesetId: string; itemActions: Array }; diff --git a/ui/types/generated/ChangesetItem.ts b/ui/types/generated/ChangesetItem.ts index 4b8928a..1f0ba8d 100644 --- a/ui/types/generated/ChangesetItem.ts +++ b/ui/types/generated/ChangesetItem.ts @@ -1,3 +1,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ChangesetItem = { id: string, changesetId: string, itemType: string, targetNodeId: string | null, proposedData: string, existingData: string | null, similarity: number | null, mergeWithId: string | null, doorId: string | null, status: string, reviewedAt: string | null, sortOrder: number, crossVaultAnomaly: boolean, anomalyWarning: string | null, }; +export type ChangesetItem = { + id: string; + changesetId: string; + itemType: string; + targetNodeId: string | null; + proposedData: string; + existingData: string | null; + similarity: number | null; + mergeWithId: string | null; + doorId: string | null; + status: string; + reviewedAt: string | null; + sortOrder: number; + crossVaultAnomaly: boolean; + anomalyWarning: string | null; +}; diff --git a/ui/types/generated/Door.ts b/ui/types/generated/Door.ts index 8e162d1..633a47c 100644 --- a/ui/types/generated/Door.ts +++ b/ui/types/generated/Door.ts @@ -1,3 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Door = { id: string, sourceNodeId: string, targetNodeId: string | null, targetVaultId: string | null, label: string | null, status: string, orphanReason: string | null, orphanSince: string | null, createdAt: string, updatedAt: string, }; +export type Door = { + id: string; + sourceNodeId: string; + targetNodeId: string | null; + targetVaultId: string | null; + label: string | null; + status: string; + orphanReason: string | null; + orphanSince: string | null; + createdAt: string; + updatedAt: string; +}; diff --git a/ui/types/generated/DoorCreateInput.ts b/ui/types/generated/DoorCreateInput.ts index 8656680..0b0a4ef 100644 --- a/ui/types/generated/DoorCreateInput.ts +++ b/ui/types/generated/DoorCreateInput.ts @@ -1,3 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DoorCreateInput = { sourceNodeId: string, targetNodeId?: string, targetVaultId?: string, label?: string, }; +export type DoorCreateInput = { + sourceNodeId: string; + targetNodeId?: string; + targetVaultId?: string; + label?: string; +}; diff --git a/ui/types/generated/ItemReviewAction.ts b/ui/types/generated/ItemReviewAction.ts index 01e25d5..9be6223 100644 --- a/ui/types/generated/ItemReviewAction.ts +++ b/ui/types/generated/ItemReviewAction.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ItemReviewAction = { itemId: string, action: string, editedData: unknown, }; +export type ItemReviewAction = { itemId: string; action: string; editedData: unknown }; diff --git a/ui/types/generated/Node.ts b/ui/types/generated/Node.ts index 3b93cab..e234307 100644 --- a/ui/types/generated/Node.ts +++ b/ui/types/generated/Node.ts @@ -1,3 +1,22 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Node = { id: string, vaultId: string, subVaultId: string | null, nodeType: string, title: string, summary: string, detail: string | null, source: string | null, sourceType: string | null, privacyTier: string | null, priority: string, version: number, isArchived: boolean, createdAt: string, updatedAt: string, lastAccessed: string, deletedAt: string | null, meta: string, }; +export type Node = { + id: string; + vaultId: string; + subVaultId: string | null; + nodeType: string; + title: string; + summary: string; + detail: string | null; + source: string | null; + sourceType: string | null; + privacyTier: string | null; + priority: string; + version: number; + isArchived: boolean; + createdAt: string; + updatedAt: string; + lastAccessed: string; + deletedAt: string | null; + meta: string; +}; diff --git a/ui/types/generated/NodeCreateInput.ts b/ui/types/generated/NodeCreateInput.ts index 15729ca..81f3ad4 100644 --- a/ui/types/generated/NodeCreateInput.ts +++ b/ui/types/generated/NodeCreateInput.ts @@ -1,3 +1,15 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NodeCreateInput = { vaultId: string, subVaultId?: string, nodeType?: string, title: string, summary: string, detail?: string, source?: string, sourceType?: string, privacyTier?: string, priority?: string, meta?: string, }; +export type NodeCreateInput = { + vaultId: string; + subVaultId?: string; + nodeType?: string; + title: string; + summary: string; + detail?: string; + source?: string; + sourceType?: string; + privacyTier?: string; + priority?: string; + meta?: string; +}; diff --git a/ui/types/generated/NodeUpdateInput.ts b/ui/types/generated/NodeUpdateInput.ts index c909731..21603da 100644 --- a/ui/types/generated/NodeUpdateInput.ts +++ b/ui/types/generated/NodeUpdateInput.ts @@ -1,3 +1,17 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NodeUpdateInput = { id: string, vaultId?: string, subVaultId?: string, nodeType?: string, title?: string, summary?: string, detail?: string, source?: string, sourceType?: string, privacyTier?: string, priority?: string, isArchived?: boolean, meta?: string, }; +export type NodeUpdateInput = { + id: string; + vaultId?: string; + subVaultId?: string; + nodeType?: string; + title?: string; + summary?: string; + detail?: string; + source?: string; + sourceType?: string; + privacyTier?: string; + priority?: string; + isArchived?: boolean; + meta?: string; +}; diff --git a/ui/types/generated/OnboardingNodeCommitInput.ts b/ui/types/generated/OnboardingNodeCommitInput.ts index 888c034..6bc89e5 100644 --- a/ui/types/generated/OnboardingNodeCommitInput.ts +++ b/ui/types/generated/OnboardingNodeCommitInput.ts @@ -3,4 +3,12 @@ /** * Payload for committing accepted onboarding rows to persistent nodes. */ -export type OnboardingNodeCommitInput = { vaultId: string, title: string, summary: string, detail?: string, nodeType?: string, sourceType?: string, tags?: Array, }; +export type OnboardingNodeCommitInput = { + vaultId: string; + title: string; + summary: string; + detail?: string; + nodeType?: string; + sourceType?: string; + tags?: Array; +}; diff --git a/ui/types/generated/OnboardingProposedNode.ts b/ui/types/generated/OnboardingProposedNode.ts index f851ef6..dd81d32 100644 --- a/ui/types/generated/OnboardingProposedNode.ts +++ b/ui/types/generated/OnboardingProposedNode.ts @@ -3,4 +3,13 @@ /** * Serialized onboarding extraction result for IPC / TypeScript. Enriches `onboarding::ProposedNode` with backend-derived metadata for the UI like `resolved_vault_id`. */ -export type OnboardingProposedNode = { title: string, summary: string, detail?: string, category?: string, targetVaultKey?: string, tags?: Array, nodeType?: string, resolvedVaultId?: string, }; +export type OnboardingProposedNode = { + title: string; + summary: string; + detail?: string; + category?: string; + targetVaultKey?: string; + tags?: Array; + nodeType?: string; + resolvedVaultId?: string; +}; diff --git a/ui/types/generated/Tag.ts b/ui/types/generated/Tag.ts index 2cdc3a5..230b753 100644 --- a/ui/types/generated/Tag.ts +++ b/ui/types/generated/Tag.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Tag = { id: string, name: string, color: string | null, createdAt: string, }; +export type Tag = { id: string; name: string; color: string | null; createdAt: string }; diff --git a/ui/types/generated/TagCreateInput.ts b/ui/types/generated/TagCreateInput.ts index a54c568..d94268a 100644 --- a/ui/types/generated/TagCreateInput.ts +++ b/ui/types/generated/TagCreateInput.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type TagCreateInput = { name: string, color?: string, }; +export type TagCreateInput = { name: string; color?: string }; diff --git a/ui/types/generated/Vault.ts b/ui/types/generated/Vault.ts index d201d30..dbc8099 100644 --- a/ui/types/generated/Vault.ts +++ b/ui/types/generated/Vault.ts @@ -1,3 +1,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Vault = { id: string, parentVaultId?: string, name: string, icon: string | null, description: string | null, privacyTier: string, priorityProfile: string, summaryNodeId: string | null, sortOrder: number, createdAt: string, updatedAt: string, deletedAt: string | null, meta: string, uiMetadata: string, }; +export type Vault = { + id: string; + parentVaultId?: string; + name: string; + icon: string | null; + description: string | null; + privacyTier: string; + priorityProfile: string; + summaryNodeId: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + meta: string; + uiMetadata: string; +}; diff --git a/ui/types/generated/VaultCreateInput.ts b/ui/types/generated/VaultCreateInput.ts index 8bd15be..fa3d07f 100644 --- a/ui/types/generated/VaultCreateInput.ts +++ b/ui/types/generated/VaultCreateInput.ts @@ -1,3 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type VaultCreateInput = { name: string, parentVaultId?: string, icon?: string, description?: string, privacyTier?: string, priorityProfile?: string, sortOrder?: number, meta?: string, }; +export type VaultCreateInput = { + name: string; + parentVaultId?: string; + icon?: string; + description?: string; + privacyTier?: string; + priorityProfile?: string; + sortOrder?: number; + meta?: string; +}; diff --git a/ui/types/generated/VaultUpdateInput.ts b/ui/types/generated/VaultUpdateInput.ts index 409a9cc..fcea418 100644 --- a/ui/types/generated/VaultUpdateInput.ts +++ b/ui/types/generated/VaultUpdateInput.ts @@ -1,3 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type VaultUpdateInput = { id: string, name?: string, privacyTier?: string, priorityProfile?: string, icon?: string, description?: string, }; +export type VaultUpdateInput = { + id: string; + name?: string; + privacyTier?: string; + priorityProfile?: string; + icon?: string; + description?: string; +}; From 97ce7babc084d032a4a396134a26b004e37aafc8 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 00:14:29 -0400 Subject: [PATCH 10/59] implement core IPC commands and utilities including changeset seeding, file export, and rate limiting --- core/src/lib.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index 9da07f0..ff6727d 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -601,10 +601,23 @@ pub async fn execute_memory_extraction_pipeline( .len() .checked_sub(2) .map(|i| chat_history[i].content.as_str()); - memory_agent::correction::detect_correction_signal(latest.unwrap_or(""), previous, &[]) - .unwrap_or(memory_agent::correction::CorrectionSignal::ExplicitPhrase { - phrase: "correction".to_string(), - }) + + let pending_data: Vec = conn + .prepare("SELECT proposed_data FROM changeset_items WHERE status = 'pending';") + .map_err(|err| format!("Failed preparing pending changeset query: {err}"))? + .query_map([], |row| row.get(0)) + .map_err(|err| format!("Failed querying pending changeset items: {err}"))? + .collect::, _>>() + .map_err(|err| format!("Failed reading pending changeset row: {err}"))?; + + memory_agent::correction::detect_correction_signal( + latest.unwrap_or(""), + previous, + &pending_data, + ) + .unwrap_or(memory_agent::correction::CorrectionSignal::ExplicitPhrase { + phrase: "correction".to_string(), + }) }; let (id, _amended) = memory_agent::amendment::amend_or_create_changeset( From 7db96b668a7331517bd72d5d4c06ce368b620d4f Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 00:22:30 -0400 Subject: [PATCH 11/59] implement amendment module to update or create pending memory changesets based on similarity matching --- core/src/memory_agent/amendment.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 01d8082..cb32d35 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -247,10 +247,24 @@ pub fn amend_or_create_changeset( let pending_items = load_pending_items(&tx, &existing_id)?; for candidate in candidates { + let resolved_vault_id = candidate + .target_vault_key + .as_deref() + .and_then(crate::onboarding::vault_id_for_category_key) + .unwrap_or("vault_root_graph") + .to_string(); + // Build the base proposed_data for this candidate. let candidate_data = serde_json::json!({ - "title": candidate.title, - "summary": candidate.summary, + "title": candidate.title, + "summary": candidate.summary, + "detail": candidate.detail, + "nodeType": candidate.node_type, + "targetVaultKey": candidate.target_vault_key, + "vaultId": resolved_vault_id, + "tags": candidate.tags, + "confidence": candidate.confidence, + "action": candidate.action, }); let candidate_fp = candidate_fingerprint(&candidate_data); From 15cef3a2e3f838b3ca256eaba73631fb7833a5b8 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 00:25:11 -0400 Subject: [PATCH 12/59] Fixed Broken Loop & Redundant Check --- core/src/memory_agent/correction.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/memory_agent/correction.rs b/core/src/memory_agent/correction.rs index 050a29b..a5b6057 100644 --- a/core/src/memory_agent/correction.rs +++ b/core/src/memory_agent/correction.rs @@ -64,7 +64,6 @@ pub fn detect_correction_signal( if message_lower.contains(&format!("not {}", word)) || message_lower.contains(&format!("no, {}", word)) || message_lower.contains(&format!("no {}", word)) - || message_lower.contains(&format!("it's {}, not {}", word, word)) { return Some(CorrectionSignal::Negation { negated_fragment: word.to_string(), From 234f16cbdd1f861823ebcac2f0052b20532566ac Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 00:41:43 -0400 Subject: [PATCH 13/59] feat: implement amendment module to support updating existing pending changesets with Jaccard-based similarity matching --- core/src/memory_agent/amendment.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index cb32d35..1a96902 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -62,8 +62,9 @@ fn chrono_now_iso() -> String { ) } +#[allow(clippy::manual_is_multiple_of)] fn is_leap(year: u64) -> bool { - year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) } /// Computes Jaccard similarity between two strings at the whitespace-token level. From e4de6f743244c15d80b29f3ea5fd99ddaadbdebe Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 00:43:55 -0400 Subject: [PATCH 14/59] Fixed Off-By-One Message Query --- core/src/memory_agent/trigger.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index f848647..d2f22e6 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -124,7 +124,7 @@ pub fn should_extract_correction( // 1. Query latest user message prior to this one in session let previous_message: Option = conn .query_row( - "SELECT content FROM session_messages WHERE session_id = ?1 ORDER BY created_at DESC LIMIT 1;", + "SELECT content FROM session_messages WHERE session_id = ?1 ORDER BY created_at DESC LIMIT 1 OFFSET 1;", [session_id], |row| row.get(0), ) From e9de02e565cc5afb7b2d40f75ecdb70978224efd Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:13:50 -0400 Subject: [PATCH 15/59] implement ChatPanel component with message rendering, editing, and streaming support --- ui/components/ChatPanel.tsx | 47 +++++++++++++++---------------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 0f141ba..6e642de 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -299,6 +299,24 @@ const ChatMessageBubble = React.memo(function ChatMessageBubble({ ); }); +async function resolveLlmConfig(): Promise<{ + provider: string; + endpoint: string; + model: string; +}> { + const provider = getLlmProvider(); + let endpoint = ""; + if (provider === "lmstudio") { + endpoint = getLmStudioEndpoint(); + } else if (provider === "ollama") { + endpoint = getOllamaEndpoint(); + } else if (["openai", "anthropic", "google", "xai"].includes(provider)) { + endpoint = await getApiKey(provider); + } + const model = getLlmModel(); + return { provider, endpoint, model }; +} + type ChatPanelProps = { selectedNodeIds: string[]; scope: ContextAssemblerScope; @@ -361,24 +379,6 @@ function ChatPanel({ } }, [chartsEnabled, setChatChartsEnabled]); - async function resolveLlmConfig(): Promise<{ - provider: string; - endpoint: string; - model: string; - }> { - const provider = getLlmProvider(); - let endpoint = ""; - if (provider === "lmstudio") { - endpoint = getLmStudioEndpoint(); - } else if (provider === "ollama") { - endpoint = getOllamaEndpoint(); - } else if (["openai", "anthropic", "google", "xai"].includes(provider)) { - endpoint = await getApiKey(provider); - } - const model = getLlmModel(); - return { provider, endpoint, model }; - } - const handleForceExtract = useCallback(async () => { if (isExtracting || isSending) return; setIsExtracting(true); @@ -571,16 +571,7 @@ function ChatPanel({ setIsSending(true); try { - const provider = getLlmProvider(); - let endpoint = ""; - if (provider === "lmstudio") { - endpoint = getLmStudioEndpoint(); - } else if (provider === "ollama") { - endpoint = getOllamaEndpoint(); - } else if (["openai", "anthropic", "google", "xai"].includes(provider)) { - endpoint = await getApiKey(provider); - } - const model = getLlmModel(); + const { provider, endpoint, model } = await resolveLlmConfig(); let executionPrompt = promptText; if (agentMode === "Ingest/Memory") { From c6472ef01080dedeeaab45e697233c3be8d3e21c Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:16:21 -0400 Subject: [PATCH 16/59] Promise Cache inside getNodes() --- ui/services/nodes.ts | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/ui/services/nodes.ts b/ui/services/nodes.ts index d2fa34a..a744416 100644 --- a/ui/services/nodes.ts +++ b/ui/services/nodes.ts @@ -18,10 +18,12 @@ import { unwrapIpcResult } from "./ipcResult"; let cachedNodes: Node[] | null = null; let cachedUnlockState: boolean | null = null; +let pendingNodesPromise: Promise | null = null; export function clearNodesCache(): void { cachedNodes = null; cachedUnlockState = null; + pendingNodesPromise = null; } export async function createNode(input: NodeCreateInput): Promise { @@ -34,20 +36,45 @@ export async function getNode(nodeId: string): Promise { } export async function getNodes(isRedactedUnlocked?: boolean): Promise { - if (isRedactedUnlocked === undefined) { - clearNodesCache(); - return unwrapIpcResult(nodeList()); - } - if (cachedUnlockState !== isRedactedUnlocked) { + if (isRedactedUnlocked !== undefined && cachedUnlockState !== isRedactedUnlocked) { cachedNodes = null; + pendingNodesPromise = null; cachedUnlockState = isRedactedUnlocked; } + + if (isRedactedUnlocked === undefined) { + if (pendingNodesPromise) { + return pendingNodesPromise; + } + const promise = unwrapIpcResult(nodeList()).finally(() => { + if (pendingNodesPromise === promise) { + pendingNodesPromise = null; + } + }); + pendingNodesPromise = promise; + return promise; + } + if (cachedNodes) { return cachedNodes; } - const nodes = await unwrapIpcResult(nodeList()); - cachedNodes = nodes; - return nodes; + if (pendingNodesPromise) { + return pendingNodesPromise; + } + + const promise = unwrapIpcResult(nodeList()).then( + (nodes) => { + cachedNodes = nodes; + pendingNodesPromise = null; + return nodes; + }, + (error) => { + pendingNodesPromise = null; + throw error; + } + ); + pendingNodesPromise = promise; + return promise; } export async function getAllNodes(isRedactedUnlocked?: boolean): Promise { From 01deee042049ecbb89a55677945640156c9f6a85 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:22:52 -0400 Subject: [PATCH 17/59] Database Query Session Filtering --- core/src/memory_agent/trigger.rs | 11 ++++-- core/tests/correction_amendment.rs | 62 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index d2f22e6..457b5d8 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -131,11 +131,16 @@ pub fn should_extract_correction( .optional() .map_err(|err| format!("Failed querying latest message: {err}"))?; - // 2. Query all pending changeset_items with status 'pending' and extract their proposed_data column values + // 2. Query all pending changeset_items with status 'pending' for this session and extract their proposed_data column values let pending_data: Vec = conn - .prepare("SELECT proposed_data FROM changeset_items WHERE status = 'pending';") + .prepare( + "SELECT ci.proposed_data \ + FROM changeset_items ci \ + JOIN changesets c ON ci.changeset_id = c.id \ + WHERE ci.status = 'pending' AND c.session_id = ?1;", + ) .map_err(|err| format!("Failed preparing pending changeset query: {err}"))? - .query_map([], |row| row.get(0)) + .query_map([session_id], |row| row.get(0)) .map_err(|err| format!("Failed querying pending changeset items: {err}"))? .collect::, _>>() .map_err(|err| format!("Failed reading pending changeset row: {err}"))?; diff --git a/core/tests/correction_amendment.rs b/core/tests/correction_amendment.rs index b980a6b..a430300 100644 --- a/core/tests/correction_amendment.rs +++ b/core/tests/correction_amendment.rs @@ -167,6 +167,68 @@ fn test_should_extract_correction_bypasses_debounce() -> Result<(), Box Result<(), Box> { + let mut conn = setup_test_db()?; + let session_a = "session-A"; + let session_b = "session-B"; + + // Setup vaults and sessions + let _ = conn.execute( + "INSERT OR IGNORE INTO vaults (id, name, privacy_tier) VALUES ('vault-root', 'Vault Root', 'open');", + [], + ); + create_test_session(&conn, session_a, "vault-root")?; + create_test_session(&conn, session_b, "vault-root")?; + + // Create a pending changeset for session_b containing "Blue Theme" + let pending_items = vec![PendingChangesetItem { + item_type: ChangesetItemType::Add, + target_node_id: None, + proposed_data: r#"{"title":"Blue Theme","summary":"This is a beautiful blue theme"}"# + .to_string(), + existing_data: None, + similarity: None, + merge_with_id: None, + }]; + let pending_changeset = PendingChangeset { + session_id: session_b.to_string(), + model_used: Some("llama3".to_string()), + items: pending_items, + }; + + let tx = conn.transaction()?; + persist_changeset(&tx, &pending_changeset, Some("llama3"))?; + tx.commit()?; + + // Insert 3 messages in session_a + insert_test_message(&conn, "msg_a1", session_a, "user", "Hello")?; + insert_test_message(&conn, "msg_a2", session_a, "assistant", "Hi")?; + insert_test_message(&conn, "msg_a3", session_a, "user", "Some query")?; + + // Insert 3 messages in session_b + insert_test_message(&conn, "msg_b1", session_b, "user", "Hello")?; + insert_test_message(&conn, "msg_b2", session_b, "assistant", "Hi")?; + insert_test_message(&conn, "msg_b3", session_b, "user", "Some other query")?; + + // If we check session_a for "Blue Theme is wrong", it should return FALSE, + // because "Blue Theme" is in session_b's pending changesets, not session_a's. + let ready_a = should_extract_correction(&conn, session_a, "Blue Theme is wrong")?; + assert!( + !ready_a, + "Session A should not be contaminated by Session B's changesets" + ); + + // If we check session_b for "Blue Theme is wrong", it should return TRUE. + let ready_b = should_extract_correction(&conn, session_b, "Blue Theme is wrong")?; + assert!( + ready_b, + "Session B should detect contradiction on its own pending changeset" + ); + + Ok(()) +} + #[test] fn test_amend_existing_changeset_in_place() -> Result<(), Box> { let mut conn = setup_test_db()?; From 529db4b2d156aa2338039394d91670db514c7f7f Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:25:46 -0400 Subject: [PATCH 18/59] clear cache --- ui/services/nodes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/services/nodes.ts b/ui/services/nodes.ts index a744416..6a4b5ff 100644 --- a/ui/services/nodes.ts +++ b/ui/services/nodes.ts @@ -46,6 +46,7 @@ export async function getNodes(isRedactedUnlocked?: boolean): Promise { if (pendingNodesPromise) { return pendingNodesPromise; } + clearNodesCache(); const promise = unwrapIpcResult(nodeList()).finally(() => { if (pendingNodesPromise === promise) { pendingNodesPromise = null; From c88e5af0f9fe965cefcbf71b8cc8073f6e8c92c0 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:27:21 -0400 Subject: [PATCH 19/59] removed path fiorittoev/Desktop/projects/MindVault/core/src/memory_agent/ammendment.rs --- core/src/memory_agent/amendment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 1a96902..8519990 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -103,7 +103,7 @@ fn candidate_fingerprint(proposed_data: &serde_json::Value) -> String { } /// Injects `_amended` metadata into an existing proposed_data JSON value. -/// The key is prefixed with `_` so itrust-analyzer-diagnostics-view:/diagnostic%20message%20%5B4%5D?4#file:///Users/fiorittoev/Desktop/projects/MindVault/core/src/memory_agent/ammendment.rs sorts before all plain field names under +/// The key is prefixed with `_` so it sorts before all plain field names under /// BTreeMap (serde_json default) and is also first when `preserve_order` / /// IndexMap is enabled (explicit prepend). /// From 59d03191ada1256b592a9d8cda113be8b77fc3d7 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:29:13 -0400 Subject: [PATCH 20/59] Simplified Function Signature: Refactored the return type of find_pending_changeset to Result, String> and Simplified Call Site: Updated the call site inside amend_or_create_changeset to fetch the ID directly without tuple destructuring --- core/src/memory_agent/amendment.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 8519990..2147696 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -137,12 +137,12 @@ fn stamp_amended( } /// Finds the most-recent pending changeset for a (session_id, model) pair. -/// Returns `Some((changeset_id, content))` or `None`. +/// Returns `Some(changeset_id)` or `None`. fn find_pending_changeset( conn: &Connection, session_id: &str, model: &str, -) -> Result, String> { +) -> Result, String> { let mut stmt = conn .prepare( "SELECT id FROM changesets @@ -157,7 +157,7 @@ fn find_pending_changeset( if let Some(row) = rows.next().map_err(|e| format!("Database error: {}", e))? { let id: String = row.get(0).map_err(|e| format!("Database error: {}", e))?; - Ok(Some((id, String::new()))) + Ok(Some(id)) } else { Ok(None) } @@ -237,7 +237,7 @@ pub fn amend_or_create_changeset( } // ── 2b. Pending changeset exists — amend in-place where possible ───────── - let (existing_id, _content) = + let existing_id = existing_changeset.ok_or_else(|| "Pending changeset unexpectedly missing".to_string())?; let tx = conn From 67e277b6fa53c2d8c1966c0b5812d556de33a22a Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:32:24 -0400 Subject: [PATCH 21/59] UTF-8 Byte Length Aware Advancement --- core/src/memory_agent/correction.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/core/src/memory_agent/correction.rs b/core/src/memory_agent/correction.rs index a5b6057..7f92d43 100644 --- a/core/src/memory_agent/correction.rs +++ b/core/src/memory_agent/correction.rs @@ -33,7 +33,11 @@ fn contains_phrase_with_boundaries(message: &str, phrase: &str) -> bool { if before_ok && after_ok { return true; } - start = abs_pos + 1; + let char_len = message[abs_pos..] + .chars() + .next() + .map_or(1, |c| c.len_utf8()); + start = abs_pos + char_len; } false } @@ -140,3 +144,14 @@ pub fn has_correction_signal( ) -> bool { detect_correction_signal(message, previous_message, pending_proposed_data).is_some() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_contains_phrase_with_boundaries_unicode_start() { + // This test would trigger a panic with the original `start = abs_pos + 1` logic + assert!(!contains_phrase_with_boundaries("a⚠️test", "⚠️test")); + } +} From 377ef3de12638bf77acd96f38fcbfbae4e1c54b1 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:34:37 -0400 Subject: [PATCH 22/59] Punctuation Trimming: Updated the direct negation scan loop inside correction.rs to strip non-alphanumeric punctuation from the ends of each split word before scanning --- core/src/memory_agent/correction.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/core/src/memory_agent/correction.rs b/core/src/memory_agent/correction.rs index 7f92d43..4ea127b 100644 --- a/core/src/memory_agent/correction.rs +++ b/core/src/memory_agent/correction.rs @@ -64,13 +64,17 @@ pub fn detect_correction_signal( if let Some(prev) = previous_message { let prev_lower = prev.to_lowercase(); for word in prev_lower.split_whitespace() { + let clean_word = word.trim_matches(|c: char| !c.is_alphanumeric()); + if clean_word.is_empty() { + continue; + } // Check if current message negates a specific word/phrase from the previous message - if message_lower.contains(&format!("not {}", word)) - || message_lower.contains(&format!("no, {}", word)) - || message_lower.contains(&format!("no {}", word)) + if message_lower.contains(&format!("not {}", clean_word)) + || message_lower.contains(&format!("no, {}", clean_word)) + || message_lower.contains(&format!("no {}", clean_word)) { return Some(CorrectionSignal::Negation { - negated_fragment: word.to_string(), + negated_fragment: clean_word.to_string(), }); } } @@ -154,4 +158,17 @@ mod tests { // This test would trigger a panic with the original `start = abs_pos + 1` logic assert!(!contains_phrase_with_boundaries("a⚠️test", "⚠️test")); } + + #[test] + fn test_negation_scan_ignores_punctuation() { + let prev = "My favorite color is blue."; + let current = "not blue"; + let signal = detect_correction_signal(current, Some(prev), &[]); + assert_eq!( + signal, + Some(CorrectionSignal::Negation { + negated_fragment: "blue".to_string() + }) + ); + } } From 6742e29cbefeffdbaa8b65f7681b2a6a72f1550e Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:36:15 -0400 Subject: [PATCH 23/59] Updated the query in trigger.rs to include id DESC in the ORDER BY clause --- core/src/memory_agent/trigger.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index 457b5d8..2a61cf6 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -124,7 +124,7 @@ pub fn should_extract_correction( // 1. Query latest user message prior to this one in session let previous_message: Option = conn .query_row( - "SELECT content FROM session_messages WHERE session_id = ?1 ORDER BY created_at DESC LIMIT 1 OFFSET 1;", + "SELECT content FROM session_messages WHERE session_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 1 OFFSET 1;", [session_id], |row| row.get(0), ) From e15085250e5bb54657f358d90d61413e743a19ad Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:41:37 -0400 Subject: [PATCH 24/59] Removed Duplicate Helper: Deleted the private jaccard_similarity helper function inside amendment.rs Reused Library Function: Replaced the call site at line 278 (now line 261) with a direct reference to the re-exported module function --- core/src/memory_agent/amendment.rs | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 2147696..8480bf5 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -67,25 +67,6 @@ fn is_leap(year: u64) -> bool { year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) } -/// Computes Jaccard similarity between two strings at the whitespace-token level. -fn jaccard_similarity(a: &str, b: &str) -> f64 { - let set_a: std::collections::HashSet<&str> = a.split_whitespace().collect(); - let set_b: std::collections::HashSet<&str> = b.split_whitespace().collect(); - - if set_a.is_empty() && set_b.is_empty() { - return 1.0; - } - - let intersection = set_a.intersection(&set_b).count(); - let union = set_a.union(&set_b).count(); - - if union == 0 { - 0.0 - } else { - intersection as f64 / union as f64 - } -} - /// Extracts a lowercase `"title summary"` string from a proposed_data JSON value /// for use as the Jaccard comparison fingerprint. fn candidate_fingerprint(proposed_data: &serde_json::Value) -> String { @@ -275,7 +256,7 @@ pub fn amend_or_create_changeset( .iter() .map(|(item_id, existing_data)| { let existing_fp = candidate_fingerprint(existing_data); - let sim = jaccard_similarity(&candidate_fp, &existing_fp); + let sim = memory_agent::jaccard_similarity(&candidate_fp, &existing_fp); (item_id, sim) }) .filter(|(_, sim)| *sim > 0.5) From 210452493d2be6ac9066217bd4d2d98da2fe7c25 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:45:20 -0400 Subject: [PATCH 25/59] Updated getNodes(isRedactedUnlocked?: boolean) to map the requested unlock state to three distinct statuses: true, false, and null (representing undefined). If the newly requested state changes (e.g. from scoped true/false to undefined, or vice versa), the active pending promise (pendingNodesPromise) and the cached nodes (cachedNodes) are cleared first to prevent returning cached data from an incorrect unlock state scope. --- ui/services/nodes.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/services/nodes.ts b/ui/services/nodes.ts index 6a4b5ff..9d0fcbf 100644 --- a/ui/services/nodes.ts +++ b/ui/services/nodes.ts @@ -36,10 +36,11 @@ export async function getNode(nodeId: string): Promise { } export async function getNodes(isRedactedUnlocked?: boolean): Promise { - if (isRedactedUnlocked !== undefined && cachedUnlockState !== isRedactedUnlocked) { + const requestedState = isRedactedUnlocked !== undefined ? isRedactedUnlocked : null; + if (cachedUnlockState !== requestedState) { cachedNodes = null; pendingNodesPromise = null; - cachedUnlockState = isRedactedUnlocked; + cachedUnlockState = requestedState; } if (isRedactedUnlocked === undefined) { From 25ac4248f0a8c708c02f14d7c4f6053ae8d21aa8 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 01:50:06 -0400 Subject: [PATCH 26/59] Modified WikiLinkBadge to look up nodes inside ExistingNodesContext if it exists, completely avoiding concurrent mounts from executing getAllNodes() and its array traversals on every wikilink badge mount. If ExistingNodesContext is not provided (backwards compatibility), it safely falls back to direct asynchronous query. --- ui/App.tsx | 1 + ui/components/ChatPanel.tsx | 41 ++++++++++++++++++++++------ ui/components/NodeEditor.tsx | 5 ++++ ui/components/NodeEditorDetail.tsx | 19 ++++++++----- ui/components/NodeEditorExpanded.tsx | 21 +++++++++----- ui/utils/markdownUtils.tsx | 24 +++++++++------- 6 files changed, 79 insertions(+), 32 deletions(-) diff --git a/ui/App.tsx b/ui/App.tsx index 277a824..1693807 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -562,6 +562,7 @@ function App() { isRedactedUnlocked={isRedactedUnlocked} onModalToggle={setChatModalOpen} onSelectNode={onSelectNode} + nodeRefreshKey={nodeRefreshKey} onRefreshPendingCount={() => { void countPendingChangesetItems() .then(setPendingProposalCount) diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 6e642de..42a6048 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -13,6 +13,7 @@ import { createMarkdownComponents, preprocessMathDelimiters, preprocessWikiLinks, + ExistingNodesContext, } from "../utils/markdownUtils"; import type { ContextAssemblerScope } from "../constants/contextBudget"; import type { Vault } from "../ipc"; @@ -23,7 +24,7 @@ import { chatEditAndTruncate, type ChatMessage, } from "../services/chat"; -import { chatWithScope } from "../services/nodes"; +import { chatWithScope, getAllNodes } from "../services/nodes"; import { getSetting } from "../services/settings"; import { listVaults } from "../services/vaults"; import { extractMemoryIfReady, extractMemoryForce } from "../services/memoryAgent"; @@ -56,6 +57,7 @@ type ChatMessageBubbleProps = { onStartEdit: (messageId: string, content: string) => void; chartsEnabled: boolean; onSelectNode?: (nodeId: string) => void; + existingNodeIds: Set | null; }; const ChatMessageBubble = React.memo(function ChatMessageBubble({ @@ -73,6 +75,7 @@ const ChatMessageBubble = React.memo(function ChatMessageBubble({ onStartEdit, chartsEnabled, onSelectNode, + existingNodeIds, }: ChatMessageBubbleProps) { const bubbleContentRef = useRef(null); const [isOverflowing, setIsOverflowing] = useState(false); @@ -135,13 +138,15 @@ const ChatMessageBubble = React.memo(function ChatMessageBubble({ message.role === "user" && isOverflowing && isCollapsed ? "collapsed" : "" }`} > - - {preprocessedMessage} - + + + {preprocessedMessage} + +
{message.role === "user" && isOverflowing && ( + <> + + + {modal && + createPortal( +
setModal(null)}> +
e.stopPropagation()}> +

{modal.title}

+

+ {modal.message} +

+
+ +
+
+
, + document.body + )} + ); } /** From 3f70181c42f2704ebcf60870a708c5f52d4121d2 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:13:16 -0400 Subject: [PATCH 36/59] Modified the query in lib.rs to join with the changesets table and filter specifically on session_id = 'default-session'. --- core/src/lib.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index 762e7e0..945ff7b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -604,7 +604,12 @@ pub async fn execute_memory_extraction_pipeline( .map(|m| m.content.as_str()); let pending_data: Vec = conn - .prepare("SELECT proposed_data FROM changeset_items WHERE status = 'pending';") + .prepare( + "SELECT ci.proposed_data \ + FROM changeset_items ci \ + JOIN changesets c ON ci.changeset_id = c.id \ + WHERE ci.status = 'pending' AND c.session_id = 'default-session';", + ) .map_err(|err| format!("Failed preparing pending changeset query: {err}"))? .query_map([], |row| row.get(0)) .map_err(|err| format!("Failed querying pending changeset items: {err}"))? From 6936a539a6898be0c5ccfee60f09da2d6834a58e Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:14:28 -0400 Subject: [PATCH 37/59] Modified the isBroken computation inside the WikiLinkBadge component to explicitly check that the link is not a search query --- ui/utils/markdownUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/utils/markdownUtils.tsx b/ui/utils/markdownUtils.tsx index 24f6c07..b3f2776 100644 --- a/ui/utils/markdownUtils.tsx +++ b/ui/utils/markdownUtils.tsx @@ -183,7 +183,7 @@ function WikiLinkBadge({ const isSearchQuery = nodeId.startsWith("search:"); const nodeExists = existingNodeIds ? existingNodeIds.has(nodeId) : fetchedExists; - const isBroken = nodeExists === false; + const isBroken = !isSearchQuery && nodeExists === false; const isLoading = nodeExists === null && !isSearchQuery; // Validate node existence on mount (skip for search queries or if context provides it) From f538effd8bab28a3e260675698aa6e298ec2d733 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:18:47 -0400 Subject: [PATCH 38/59] implement expanded node editor with Markdown preview, tag management, and door linking functionality --- ui/components/ChatPanel.tsx | 7 +++++-- ui/components/NodeEditor.tsx | 1 + ui/components/NodeEditorDetail.tsx | 6 ++++-- ui/components/NodeEditorExpanded.tsx | 4 ++-- ui/utils/markdownUtils.tsx | 17 ++++++++++++----- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 2b83943..4bf39bc 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -58,6 +58,7 @@ type ChatMessageBubbleProps = { chartsEnabled: boolean; onSelectNode?: (nodeId: string) => void; existingNodeIds: Set | null; + isRedactedUnlocked: boolean; }; const ChatMessageBubble = React.memo(function ChatMessageBubble({ @@ -76,6 +77,7 @@ const ChatMessageBubble = React.memo(function ChatMessageBubble({ chartsEnabled, onSelectNode, existingNodeIds, + isRedactedUnlocked, }: ChatMessageBubbleProps) { const bubbleContentRef = useRef(null); const [isOverflowing, setIsOverflowing] = useState(false); @@ -91,8 +93,8 @@ const ChatMessageBubble = React.memo(function ChatMessageBubble({ }, [message.content, message.role, isEditing]); const markdownComponents = React.useMemo(() => { - return createMarkdownComponents(chartsEnabled, onSelectNode); - }, [chartsEnabled, onSelectNode]); + return createMarkdownComponents(chartsEnabled, onSelectNode, isRedactedUnlocked); + }, [chartsEnabled, onSelectNode, isRedactedUnlocked]); const preprocessedMessage = React.useMemo(() => { const wLinks = preprocessWikiLinks(message.content); @@ -1130,6 +1132,7 @@ function ChatPanel({ chartsEnabled={chartsEnabled} onSelectNode={onSelectNode} existingNodeIds={existingNodeIds} + isRedactedUnlocked={isRedactedUnlocked} /> ))} {isSending && ( diff --git a/ui/components/NodeEditor.tsx b/ui/components/NodeEditor.tsx index d2dd68f..168e2c7 100644 --- a/ui/components/NodeEditor.tsx +++ b/ui/components/NodeEditor.tsx @@ -1074,6 +1074,7 @@ function NodeEditor({ nodeId={node?.id} onRefreshDoors={() => node?.id && refreshDoors(node.id)} existingNodeIds={existingNodeIds} + isRedactedUnlocked={isRedactedUnlocked} /> )}
diff --git a/ui/components/NodeEditorDetail.tsx b/ui/components/NodeEditorDetail.tsx index 1fed263..2cd952c 100644 --- a/ui/components/NodeEditorDetail.tsx +++ b/ui/components/NodeEditorDetail.tsx @@ -27,6 +27,7 @@ type NodeEditorDetailProps = { nodeId?: string; onRefreshDoors?: () => void; existingNodeIds?: Set; + isRedactedUnlocked?: boolean; }; export default function NodeEditorDetail({ @@ -40,6 +41,7 @@ export default function NodeEditorDetail({ nodeId, onRefreshDoors, existingNodeIds, + isRedactedUnlocked, }: NodeEditorDetailProps) { const storeChartsEnabled = useUIStore((state) => state.nodeEditor.chartsEnabled); const setNodeEditorChartsEnabled = useUIStore((state) => state.setNodeEditorChartsEnabled); @@ -166,8 +168,8 @@ export default function NodeEditorDetail({ }, [debouncedPreviewValue]); const markdownComponents = React.useMemo(() => { - return createMarkdownComponents(chartsEnabled, onSelectNode); - }, [chartsEnabled, onSelectNode]); + return createMarkdownComponents(chartsEnabled, onSelectNode, isRedactedUnlocked); + }, [chartsEnabled, onSelectNode, isRedactedUnlocked]); return (
diff --git a/ui/components/NodeEditorExpanded.tsx b/ui/components/NodeEditorExpanded.tsx index 22c3206..89dcc6d 100644 --- a/ui/components/NodeEditorExpanded.tsx +++ b/ui/components/NodeEditorExpanded.tsx @@ -784,8 +784,8 @@ export default function NodeEditorExpanded({ }, [debouncedPreviewDetail]); const markdownComponents = React.useMemo(() => { - return createMarkdownComponents(chartsEnabled, onSelectNode); - }, [chartsEnabled, onSelectNode]); + return createMarkdownComponents(chartsEnabled, onSelectNode, isRedactedUnlocked); + }, [chartsEnabled, onSelectNode, isRedactedUnlocked]); const existingNodeIds = React.useMemo(() => { return new Set(Object.keys(allNodesMap)); diff --git a/ui/utils/markdownUtils.tsx b/ui/utils/markdownUtils.tsx index b3f2776..e1d4644 100644 --- a/ui/utils/markdownUtils.tsx +++ b/ui/utils/markdownUtils.tsx @@ -172,10 +172,12 @@ function WikiLinkBadge({ nodeId, children, onSelectNode, + isRedactedUnlocked, }: { nodeId: string; children: React.ReactNode; onSelectNode?: (nodeId: string) => void; + isRedactedUnlocked?: boolean; }) { const existingNodeIds = React.useContext(ExistingNodesContext); const [fetchedExists, setFetchedExists] = React.useState(null); @@ -194,7 +196,7 @@ function WikiLinkBadge({ let isMounted = true; - getAllNodes() + getAllNodes(isRedactedUnlocked) .then((nodes) => { if (isMounted) { const exists = nodes.some((n) => n.id === nodeId); @@ -210,7 +212,7 @@ function WikiLinkBadge({ return () => { isMounted = false; }; - }, [nodeId, isSearchQuery, existingNodeIds]); + }, [nodeId, isSearchQuery, existingNodeIds, isRedactedUnlocked]); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); @@ -227,7 +229,7 @@ function WikiLinkBadge({ if (onSelectNode) { if (isSearchQuery) { const query = nodeId.substring(7).trim(); - getAllNodes() + getAllNodes(isRedactedUnlocked) .then((nodes) => { const match = nodes.find((n) => n.title.toLowerCase().trim() === query.toLowerCase()); if (match) { @@ -297,7 +299,8 @@ function WikiLinkBadge({ */ export function createMarkdownComponents( chartsEnabled: boolean, - onSelectNode?: (nodeId: string) => void + onSelectNode?: (nodeId: string) => void, + isRedactedUnlocked?: boolean ) { return { a({ href, children, ...props }: React.AnchorHTMLAttributes) { @@ -310,7 +313,11 @@ export function createMarkdownComponents( ?.split(/[?#]/)[0] || ""; const decodedNodeId = decodeURIComponent(nodeId); return ( - + {children} ); From 7e73e229959caaee6f78daf593fe9f542f886327 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:22:12 -0400 Subject: [PATCH 39/59] id is crypto.randomUUID() (a random UUID v4), so sorting by it produces arbitrary ordering when two messages share the same created_at timestamp (which has only second-level precision). SQLite's rowid is auto-incrementing and reflects true insertion order, making it a deterministic tie-breaker --- core/src/chat.rs | 2 +- core/src/lib.rs | 2 +- core/src/memory_agent/trigger.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/chat.rs b/core/src/chat.rs index 0ef3ed9..795a121 100644 --- a/core/src/chat.rs +++ b/core/src/chat.rs @@ -34,7 +34,7 @@ pub fn get_chat_history(db: &Connection) -> Result, crate::AppE "SELECT id, role, content, created_at FROM session_messages WHERE session_id = ?1 - ORDER BY created_at ASC, id ASC;", + ORDER BY created_at ASC, rowid ASC;", ) .map_err(|err| { eprintln!("Database error preparing chat history query: {err}"); diff --git a/core/src/lib.rs b/core/src/lib.rs index 945ff7b..5f15e90 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -675,7 +675,7 @@ fn fetch_latest_user_message( conn.query_row( "SELECT content FROM session_messages WHERE session_id = ?1 AND role = 'user' - ORDER BY created_at DESC, id DESC + ORDER BY created_at DESC, rowid DESC LIMIT 1;", [session_id], |row| row.get(0), diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index 838d5f8..20a9b28 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -124,7 +124,7 @@ pub fn should_extract_correction( // 1. Query latest user message prior to this one in session let previous_message: Option = conn .query_row( - "SELECT content FROM session_messages WHERE session_id = ?1 AND role = 'user' ORDER BY created_at DESC, id DESC LIMIT 1 OFFSET 1;", + "SELECT content FROM session_messages WHERE session_id = ?1 AND role = 'user' ORDER BY created_at DESC, rowid DESC LIMIT 1 OFFSET 1;", [session_id], |row| row.get(0), ) From 1aee0b14ab9a68fa9a23691402adf853a2ff56a5 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:24:03 -0400 Subject: [PATCH 40/59] =?UTF-8?q?changed=20ORDER=20BY=20created=5Fat=20ASC?= =?UTF-8?q?,=20id=20ASC=20=E2=86=92=20ORDER=20BY=20created=5Fat=20ASC,=20r?= =?UTF-8?q?owid=20ASC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index 5f15e90..4231ecf 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -423,7 +423,7 @@ pub async fn execute_memory_extraction_pipeline( "SELECT id, role, content, node_refs, created_at FROM session_messages WHERE session_id = 'default-session' - ORDER BY created_at ASC, id ASC;", + ORDER BY created_at ASC, rowid ASC;", ) .map_err(|err| format!("Failed preparing session_messages query: {err}"))?; From 5d39f6291e40041919fe21056240dbec831ec1f9 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:28:22 -0400 Subject: [PATCH 41/59] Added let mut is_correction = false; to track correction state (line 716) Set is_correction = true when correction_ready is detected (line 723) Passed Some(is_correction) instead of None to execute_memory_extraction_pipeline (line 738) --- core/src/lib.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index 4231ecf..4dfe8ac 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -713,6 +713,7 @@ async fn memory_extract_if_ready( // 5. Check trigger let mut ready = memory_agent::trigger::should_extract(&conn, session_id)?; + let mut is_correction = false; if !ready { let latest_user_msg = fetch_latest_user_message(&conn, session_id)?; if let Some(msg_content) = latest_user_msg { @@ -720,6 +721,7 @@ async fn memory_extract_if_ready( memory_agent::trigger::should_extract_correction(&conn, session_id, &msg_content)?; if correction_ready { ready = true; + is_correction = true; } } } @@ -733,8 +735,14 @@ async fn memory_extract_if_ready( // 5. Execute shared pipeline (capture result without early-returning on error, // so we always mark the extraction as attempted and respect cooldown windows) - let pipeline_result = - execute_memory_extraction_pipeline(provider, endpoint, model, db_path.clone(), None).await; + let pipeline_result = execute_memory_extraction_pipeline( + provider, + endpoint, + model, + db_path.clone(), + Some(is_correction), + ) + .await; // 6. Mark extraction complete/attempted *before* propagating any error, // so that should_extract respects the 6-message and 2-minute cooldown From e653d52aac54a9b7662ee26bb4ec77539d4d626b Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:30:39 -0400 Subject: [PATCH 42/59] Line 27: Added clearNodesCache to the import from ../services/nodes Line 365: Added clearNodesCache() call at the start of the useEffect that depends on nodeRefreshKey --- ui/components/ChatPanel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 4bf39bc..561224b 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -24,7 +24,7 @@ import { chatEditAndTruncate, type ChatMessage, } from "../services/chat"; -import { chatWithScope, getAllNodes } from "../services/nodes"; +import { chatWithScope, getAllNodes, clearNodesCache } from "../services/nodes"; import { getSetting } from "../services/settings"; import { listVaults } from "../services/vaults"; import { extractMemoryIfReady, extractMemoryForce } from "../services/memoryAgent"; @@ -362,6 +362,7 @@ function ChatPanel({ useEffect(() => { let active = true; + clearNodesCache(); getAllNodes(isRedactedUnlocked) .then((nodes) => { if (active) { From a7e146edbeff8299c48a9d78f0ac83114adafbe8 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:32:50 -0400 Subject: [PATCH 43/59] UPDATE path now includes item_type (derived from candidate.action) in the SQL query --- core/src/memory_agent/amendment.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 63de25c..69c4eea 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -279,14 +279,21 @@ pub fn amend_or_create_changeset( let proposed_json = serde_json::to_string(&amended_data) .map_err(|e| format!("JSON serialization error: {}", e))?; + let item_type = match candidate.action { + crate::memory_agent::CandidateAction::Add => "add", + crate::memory_agent::CandidateAction::Update => "update", + crate::memory_agent::CandidateAction::Delete => "delete", + }; + tx.execute( "UPDATE changeset_items SET proposed_data = ?1, similarity = ?2, + item_type = ?3, reviewed_at = NULL, sort_order = sort_order - WHERE id = ?3", - params![proposed_json, similarity, matched_id], + WHERE id = ?4", + params![proposed_json, similarity, item_type, matched_id], ) .map_err(|e| format!("Failed to update changeset_item {}: {}", matched_id, e))?; } else { From c08f29cdc23d3673b840f24356704650719d5e1e Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:37:06 -0400 Subject: [PATCH 44/59] =?UTF-8?q?Signature:=20fn(Value,=20...)=20->=20Resu?= =?UTF-8?q?lt=20=E2=86=92=20fn(&mut=20Value,=20...)=20Bod?= =?UTF-8?q?y:=20Replaced=20as=5Fobject()=20+=20new=20Map=20allocation=20+?= =?UTF-8?q?=20clone-all-entries=20with=20a=20single=20as=5Fobject=5Fmut()?= =?UTF-8?q?=20+=20in-place=20insert=20Call=20site:=20Removed=20.map=5Ferr(?= =?UTF-8?q?...)=20error=20handling,=20now=20just=20stamp=5Famended(&mut=20?= =?UTF-8?q?candidate=5Fdata,=20...)=20This=20eliminates=20an=20unnecessary?= =?UTF-8?q?=20heap=20allocation,=20O(n)=20key/value=20cloning,=20and=20err?= =?UTF-8?q?or-handling=20boilerplate=20for=20a=20condition=20that=20can=20?= =?UTF-8?q?never=20occur.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/memory_agent/amendment.rs | 41 +++++++++++------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 69c4eea..2fed886 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -89,32 +89,22 @@ fn candidate_fingerprint(proposed_data: &serde_json::Value) -> String { /// IndexMap is enabled (explicit prepend). /// /// Only called on the UPDATE (similarity-match) path — new inserts stay clean. +/// The caller always passes a valid JSON object, so this mutates in-place. fn stamp_amended( - proposed_data: serde_json::Value, + proposed_data: &mut serde_json::Value, similarity: f64, correction_signal: &crate::memory_agent::CorrectionSignal, -) -> Result { - let obj = proposed_data - .as_object() - .ok_or_else(|| "proposed_data is not a JSON object".to_string())?; - - let mut ordered = serde_json::Map::new(); - - // Insert `_amended` first so it leads the object regardless of feature flags. - ordered.insert( - "_amended".to_string(), - serde_json::json!({ - "at": chrono_now_iso(), - "similarity": similarity, - "reason": format!("{:?}", correction_signal), - }), - ); - - for (k, v) in obj.iter() { - ordered.insert(k.clone(), v.clone()); +) { + if let Some(obj) = proposed_data.as_object_mut() { + obj.insert( + "_amended".to_string(), + serde_json::json!({ + "at": chrono_now_iso(), + "similarity": similarity, + "reason": format!("{:?}", correction_signal), + }), + ); } - - Ok(serde_json::Value::Object(ordered)) } /// Finds the most-recent pending changeset for a (session_id, model) pair. @@ -239,7 +229,7 @@ pub fn amend_or_create_changeset( .to_string(); // Build the base proposed_data for this candidate. - let candidate_data = serde_json::json!({ + let mut candidate_data = serde_json::json!({ "title": candidate.title, "summary": candidate.summary, "detail": candidate.detail, @@ -273,10 +263,9 @@ pub fn amend_or_create_changeset( // // Stamp `_amended` metadata so the Diff Panel can render the // (amended) badge without a schema migration. - let amended_data = stamp_amended(candidate_data, similarity, correction_signal) - .map_err(|e| format!("Failed to stamp _amended on item {}: {}", matched_id, e))?; + stamp_amended(&mut candidate_data, similarity, correction_signal); - let proposed_json = serde_json::to_string(&amended_data) + let proposed_json = serde_json::to_string(&candidate_data) .map_err(|e| format!("JSON serialization error: {}", e))?; let item_type = match candidate.action { From 4bd3a76186f1dbc4adb3e68b60facba981261b25 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:38:57 -0400 Subject: [PATCH 45/59] Replaced the 58-line manual calendar decomposition (chrono_now_iso + is_leap) with a compact 32-line implementation using Howard Hinnant's civil-date algorithm. Key improvements: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminated is_leap helper — no longer needed; leap year math is handled implicitly by the era-based algorithm O(1) date calculation — the old code iterated year-by-year from 1970 (loop { year += 1 }) and month-by-month; the new algorithm computes the result directly via integer arithmetic --- core/src/memory_agent/amendment.rs | 72 ++++++++++-------------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 2fed886..3b7b27c 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -9,64 +9,38 @@ use std::time::{SystemTime, UNIX_EPOCH}; // ───────────────────────────────────────────────────────────────────────────── fn chrono_now_iso() -> String { - let now = SystemTime::now() + let dur = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); - let secs = now.as_secs(); - let millis = now.subsec_millis(); - - // Manual decomposition of Unix timestamp → UTC date/time components - let s = secs % 60; - let m = (secs / 60) % 60; - let h = (secs / 3600) % 24; - - // Days since epoch → Gregorian calendar - let mut days = secs / 86400; - let mut year = 1970u64; - loop { - let days_in_year = if is_leap(year) { 366 } else { 365 }; - if days < days_in_year { - break; - } - days -= days_in_year; - year += 1; - } - let months = [ - 31, - if is_leap(year) { 29 } else { 28 }, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31, - ]; - let mut month = 1u64; - for &days_in_month in &months { - if days < days_in_month { - break; - } - days -= days_in_month; - month += 1; - } - let day = days + 1; + let total_secs = dur.as_secs(); + let millis = dur.subsec_millis(); + + let secs_of_day = total_secs % 86_400; + let h = secs_of_day / 3_600; + let m = (secs_of_day % 3_600) / 60; + let s = secs_of_day % 60; + + // Convert days since epoch to (year, month, day) using a compact civil-date algorithm. + let mut days = (total_secs / 86_400) as i64; + // Shift epoch from 1970-01-01 to 0000-03-01 for simpler leap-year math. + days += 719_468; + let era = days.div_euclid(146_097); + let doe = days.rem_euclid(146_097); // day of era [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if mo <= 2 { y + 1 } else { y }; format!( "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", - year, month, day, h, m, s, millis + year, mo, d, h, m, s, millis ) } -#[allow(clippy::manual_is_multiple_of)] -fn is_leap(year: u64) -> bool { - year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) -} - /// Extracts a lowercase `"title summary"` string from a proposed_data JSON value /// for use as the Jaccard comparison fingerprint. fn candidate_fingerprint(proposed_data: &serde_json::Value) -> String { From 35e5a1b86aee4f67ef072a5428315440d7cfd16a Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:40:09 -0400 Subject: [PATCH 46/59] removed the no-op sort_order = sort_order self-assignment from the UPDATE query in amendment.rs:255-257. Setting a column to its own value has no effect and just adds noise to the query. --- core/src/memory_agent/amendment.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 3b7b27c..5f739dd 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -253,8 +253,7 @@ pub fn amend_or_create_changeset( SET proposed_data = ?1, similarity = ?2, item_type = ?3, - reviewed_at = NULL, - sort_order = sort_order + reviewed_at = NULL WHERE id = ?4", params![proposed_json, similarity, item_type, matched_id], ) From 80327f2f16d75f749a8b0f3b48c4ffd88b635518 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 02:49:27 -0400 Subject: [PATCH 47/59] implement DiffPanel for reviewing and committing memory changesets --- ui/components/DiffPanel.tsx | 11 ----------- ui/components/DiffPanel/DiffRow.tsx | 9 +++++++++ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/ui/components/DiffPanel.tsx b/ui/components/DiffPanel.tsx index 1d95e89..9e56096 100644 --- a/ui/components/DiffPanel.tsx +++ b/ui/components/DiffPanel.tsx @@ -590,9 +590,6 @@ export default function DiffPanel({ {/* Detailed diff cards will render here in Commit 4. For Commit 3, we render a highly polished list summary with type badges. */} {filteredItems.map((item) => { - const parsedData = parseJSON(item.proposedData); - const itemIsAmended = parsedData._amended != null; - const itemAmendedAt = itemIsAmended ? parsedData._amended.at : null; return (
- {itemIsAmended && ( - - (amended) - - )}
); })} diff --git a/ui/components/DiffPanel/DiffRow.tsx b/ui/components/DiffPanel/DiffRow.tsx index a05afaa..7a5f14f 100644 --- a/ui/components/DiffPanel/DiffRow.tsx +++ b/ui/components/DiffPanel/DiffRow.tsx @@ -17,6 +17,7 @@ interface ProposedData { tags?: string[]; vaultId?: string; vault_id?: string; + _amended?: { at?: string; similarity?: number; reason?: string }; } interface ExistingData { @@ -243,6 +244,14 @@ export default function DiffRow({ item, onCommitItem }: DiffRowProps) { {typeUpper === "REPOINT_DOOR" || typeUpper === "ORPHAN_ALERT" ? "ORPHAN" : typeUpper} + {proposed._amended && ( + + (amended) + + )} Status: {item.status}
Date: Mon, 15 Jun 2026 02:54:23 -0400 Subject: [PATCH 48/59] refactored the CorrectionSignal detection in the memory agent. --- core/src/lib.rs | 69 +++++++++++------------------- core/src/memory_agent/trigger.rs | 16 ++++--- core/tests/correction_amendment.rs | 12 +++--- 3 files changed, 42 insertions(+), 55 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index 4dfe8ac..ff00046 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -412,7 +412,7 @@ pub async fn execute_memory_extraction_pipeline( endpoint: String, model: String, db_path: PathBuf, - is_correction: Option, + correction_signal: Option, ) -> Result { // 1. Load and filter chat history synchronously within scoped block to drop connection before await let chat_history = { @@ -594,44 +594,13 @@ pub async fn execute_memory_extraction_pipeline( // 7. Reuse a single connection for changeset build, persist, and retrieval let mut conn = open_connection(&db_path)?; - let changeset_id: String = if is_correction.unwrap_or(false) { - let correction_signal = { - let latest = chat_history.last().map(|m| m.content.as_str()); - let previous = chat_history - .len() - .checked_sub(2) - .and_then(|i| chat_history.get(i)) - .map(|m| m.content.as_str()); - - let pending_data: Vec = conn - .prepare( - "SELECT ci.proposed_data \ - FROM changeset_items ci \ - JOIN changesets c ON ci.changeset_id = c.id \ - WHERE ci.status = 'pending' AND c.session_id = 'default-session';", - ) - .map_err(|err| format!("Failed preparing pending changeset query: {err}"))? - .query_map([], |row| row.get(0)) - .map_err(|err| format!("Failed querying pending changeset items: {err}"))? - .collect::, _>>() - .map_err(|err| format!("Failed reading pending changeset row: {err}"))?; - - memory_agent::correction::detect_correction_signal( - latest.unwrap_or(""), - previous, - &pending_data, - ) - .unwrap_or(memory_agent::correction::CorrectionSignal::ExplicitPhrase { - phrase: "correction".to_string(), - }) - }; - + let changeset_id: String = if let Some(ref signal) = correction_signal { let (id, _amended) = memory_agent::amendment::amend_or_create_changeset( &mut conn, &candidates, "default-session", &model, - &correction_signal, + signal, )?; id } else { @@ -713,15 +682,15 @@ async fn memory_extract_if_ready( // 5. Check trigger let mut ready = memory_agent::trigger::should_extract(&conn, session_id)?; - let mut is_correction = false; + let mut correction_signal = None; if !ready { let latest_user_msg = fetch_latest_user_message(&conn, session_id)?; if let Some(msg_content) = latest_user_msg { - let correction_ready = + let signal = memory_agent::trigger::should_extract_correction(&conn, session_id, &msg_content)?; - if correction_ready { + if signal.is_some() { ready = true; - is_correction = true; + correction_signal = signal; } } } @@ -740,7 +709,7 @@ async fn memory_extract_if_ready( endpoint, model, db_path.clone(), - Some(is_correction), + correction_signal, ) .await; @@ -3634,12 +3603,26 @@ async fn memory_extract_force( return Err("Need at least 3 messages to extract memory.".to_string()); } + // Since memory_extract_force is a manual bypass/trigger, check if there's a correction + // signal in the latest user message to see if we should run it as a correction. + let latest_user_msg = fetch_latest_user_message(&conn, "default-session")?; + let correction_signal = if let Some(msg_content) = latest_user_msg { + memory_agent::trigger::should_extract_correction(&conn, "default-session", &msg_content)? + } else { + None + }; + drop(conn); - // Execute pipeline (always as correction=true to enable amendment) - let result = - execute_memory_extraction_pipeline(provider, endpoint, model, db_path.clone(), Some(true)) - .await; + // Execute pipeline with the detected correction signal + let result = execute_memory_extraction_pipeline( + provider, + endpoint, + model, + db_path.clone(), + correction_signal, + ) + .await; // Mark extraction complete let conn = open_connection(&db_path)?; diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index 20a9b28..c6425ba 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -120,7 +120,7 @@ pub fn should_extract_correction( conn: &Connection, session_id: &str, message: &str, -) -> Result { +) -> Result, String> { // 1. Query latest user message prior to this one in session let previous_message: Option = conn .query_row( @@ -145,8 +145,12 @@ pub fn should_extract_correction( .collect::, _>>() .map_err(|err| format!("Failed reading pending changeset row: {err}"))?; - let signal: bool = - correction::has_correction_signal(message, previous_message.as_deref(), &pending_data); + let signal = + correction::detect_correction_signal(message, previous_message.as_deref(), &pending_data); + + if signal.is_none() { + return Ok(None); + } // 3. Check for signal detection and message count threshold (3) let message_count: i64 = conn @@ -157,10 +161,10 @@ pub fn should_extract_correction( ) .map_err(|err| format!("Failed querying session message count: {err}"))?; - if signal && message_count >= 3 { - Ok(true) + if message_count >= 3 { + Ok(signal) } else { - Ok(false) + Ok(None) } } diff --git a/core/tests/correction_amendment.rs b/core/tests/correction_amendment.rs index 07fb8ac..9d768d7 100644 --- a/core/tests/correction_amendment.rs +++ b/core/tests/correction_amendment.rs @@ -160,9 +160,9 @@ fn test_should_extract_correction_bypasses_debounce() -> Result<(), Box Result<(), Box Date: Mon, 15 Jun 2026 02:56:59 -0400 Subject: [PATCH 49/59] Added chrono = { version = "0.4", features = ["serde"] } to Cargo.toml Refactored chrono_now_iso: Simplified the function in amendment.rs to utilize chrono::Utc::now(), replacing the custom time decomposition arithmetic. --- core/Cargo.lock | 3 +++ core/Cargo.toml | 2 ++ core/src/memory_agent/amendment.rs | 32 +----------------------------- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 42c8ef1..3f4d0b3 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -553,8 +553,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -2216,6 +2218,7 @@ dependencies = [ "argon2", "async-trait", "base64 0.22.1", + "chrono", "governor", "rand_core 0.6.4", "reqwest 0.12.28", diff --git a/core/Cargo.toml b/core/Cargo.toml index 07f3849..56a7c65 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -34,3 +34,5 @@ async-trait = "0.1" tiktoken-rs = "0.5" rfd = "0.15" governor = "0.6" +chrono = { version = "0.4", features = ["serde"] } + diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 5f739dd..5bbecda 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -2,43 +2,13 @@ use rusqlite::{params, Connection}; use serde_json; use crate::memory_agent; -use std::time::{SystemTime, UNIX_EPOCH}; // ───────────────────────────────────────────────────────────────────────────── // Internal helpers // ───────────────────────────────────────────────────────────────────────────── fn chrono_now_iso() -> String { - let dur = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - - let total_secs = dur.as_secs(); - let millis = dur.subsec_millis(); - - let secs_of_day = total_secs % 86_400; - let h = secs_of_day / 3_600; - let m = (secs_of_day % 3_600) / 60; - let s = secs_of_day % 60; - - // Convert days since epoch to (year, month, day) using a compact civil-date algorithm. - let mut days = (total_secs / 86_400) as i64; - // Shift epoch from 1970-01-01 to 0000-03-01 for simpler leap-year math. - days += 719_468; - let era = days.div_euclid(146_097); - let doe = days.rem_euclid(146_097); // day of era [0, 146096] - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let mo = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = if mo <= 2 { y + 1 } else { y }; - - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", - year, mo, d, h, m, s, millis - ) + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } /// Extracts a lowercase `"title summary"` string from a proposed_data JSON value From e227b3a21f0c1241eac3f78d9eaa20829e8008db Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:01:13 -0400 Subject: [PATCH 50/59] Added Prop: Extended ChatPanelProps in ChatPanel.tsx to accept an optional visible?: boolean prop (defaulting to true). Guarded useEffect: Guarded the node refresh effect in ChatPanel.tsx to skip cache clearing and database querying if visible is false. Added visible to the dependency array. Propagated State: Passed visible={viewMode === "chat"} from App.tsx to the mounted component. --- ui/App.tsx | 1 + ui/components/ChatPanel.tsx | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/App.tsx b/ui/App.tsx index 1693807..39484db 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -563,6 +563,7 @@ function App() { onModalToggle={setChatModalOpen} onSelectNode={onSelectNode} nodeRefreshKey={nodeRefreshKey} + visible={viewMode === "chat"} onRefreshPendingCount={() => { void countPendingChangesetItems() .then(setPendingProposalCount) diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 561224b..962f6bd 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -335,6 +335,7 @@ type ChatPanelProps = { onRefreshPendingCount?: () => void; isRedactedUnlocked: boolean; nodeRefreshKey?: number; + visible?: boolean; }; function ChatPanel({ @@ -348,6 +349,7 @@ function ChatPanel({ onRefreshPendingCount, isRedactedUnlocked, nodeRefreshKey, + visible = true, }: ChatPanelProps) { const MAX_RENDERED_MESSAGES = 60; const [messages, setMessages] = useState([]); @@ -361,6 +363,7 @@ function ChatPanel({ const [existingNodeIds, setExistingNodeIds] = useState | null>(null); useEffect(() => { + if (!visible) return; let active = true; clearNodesCache(); getAllNodes(isRedactedUnlocked) @@ -375,7 +378,7 @@ function ChatPanel({ return () => { active = false; }; - }, [nodeRefreshKey, isRedactedUnlocked]); + }, [nodeRefreshKey, isRedactedUnlocked, visible]); const [userName, setUserName] = useState("Lisa"); const [vaults, setVaults] = useState([]); From b4d59ddf2af381c8b35720e3800f94d012c01e05 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:02:09 -0400 Subject: [PATCH 51/59] Defined labelText: Added a React.useMemo function at the top of WikiLinkBadge in markdownUtils.tsx that extracts a clean, concatenated string representation from a React.ReactNode (handling strings, numbers, arrays, and recursively traversing nested React elements). Replaced Interpolations: Substituted ${children} with ${labelText} inside all tooltip titles and modal alert template strings to prevent [object Object] rendering. --- ui/utils/markdownUtils.tsx | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/ui/utils/markdownUtils.tsx b/ui/utils/markdownUtils.tsx index e1d4644..9c55398 100644 --- a/ui/utils/markdownUtils.tsx +++ b/ui/utils/markdownUtils.tsx @@ -184,6 +184,22 @@ function WikiLinkBadge({ const [modal, setModal] = React.useState<{ title: string; message: string } | null>(null); const isSearchQuery = nodeId.startsWith("search:"); + // Extract a clean string representation of children for string templates (alert messages & tooltips) + const labelText = React.useMemo(() => { + if (typeof children === "string") return children; + if (typeof children === "number") return String(children); + const extractString = (node: React.ReactNode): string => { + if (!node) return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(extractString).join(""); + if (React.isValidElement(node)) { + return extractString((node.props as { children?: React.ReactNode }).children); + } + return ""; + }; + return extractString(children); + }, [children]); + const nodeExists = existingNodeIds ? existingNodeIds.has(nodeId) : fetchedExists; const isBroken = !isSearchQuery && nodeExists === false; const isLoading = nodeExists === null && !isSearchQuery; @@ -221,7 +237,7 @@ function WikiLinkBadge({ if (isBroken) { setModal({ title: "Broken Node Connection", - message: `The node "${children}" no longer exists in your vault.\n\nYou may need to:\n• Remove this link\n• Create a matching node with this title`, + message: `The node "${labelText}" no longer exists in your vault.\n\nYou may need to:\n• Remove this link\n• Create a matching node with this title`, }); return; } @@ -256,10 +272,10 @@ function WikiLinkBadge({ onClick={handleClick} title={ isBroken - ? `Broken link: Node "${children}" not found` + ? `Broken link: Node "${labelText}" not found` : isSearchQuery - ? `Search/Navigate to: ${children}` - : `Navigate to: ${children}` + ? `Search/Navigate to: ${labelText}` + : `Navigate to: ${labelText}` } disabled={isLoading} > From c0dd922d40998fb612f9b8cf00c4d9f8296e0580 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:03:39 -0400 Subject: [PATCH 52/59] Refactored the negation scan Build a HashSet containing the words from the previous_message (ignoring non-alphanumerics and stopwords). Tokenize the message_lower and iterate to find occurrences of the negation words "not" and "no". Perform an $O(1)$ lookup in the HashSet for the adjacent subsequent word. --- core/src/memory_agent/correction.rs | 40 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/core/src/memory_agent/correction.rs b/core/src/memory_agent/correction.rs index 3bed06a..c7d2920 100644 --- a/core/src/memory_agent/correction.rs +++ b/core/src/memory_agent/correction.rs @@ -62,27 +62,31 @@ pub fn detect_correction_signal( // 2. Direct Negation Scan if let Some(prev) = previous_message { - let prev_lower = prev.to_lowercase(); - for word in prev_lower.split_whitespace() { + use std::collections::HashSet; + let mut prev_words = HashSet::new(); + for word in prev.to_lowercase().split_whitespace() { let clean_word = word.trim_matches(|c: char| !c.is_alphanumeric()); - if clean_word.is_empty() { - continue; - } - // Filter out common stop words to prevent false-positive negation triggers - if crate::memory_agent::similarity::STOPWORDS - .binary_search(&clean_word) - .is_ok() + if !clean_word.is_empty() + && crate::memory_agent::similarity::STOPWORDS + .binary_search(&clean_word) + .is_err() { - continue; + prev_words.insert(clean_word.to_string()); } - // Check if current message negates a specific word/phrase from the previous message - if contains_phrase_with_boundaries(&message_lower, &format!("not {}", clean_word)) - || contains_phrase_with_boundaries(&message_lower, &format!("no, {}", clean_word)) - || contains_phrase_with_boundaries(&message_lower, &format!("no {}", clean_word)) - { - return Some(CorrectionSignal::Negation { - negated_fragment: clean_word.to_string(), - }); + } + + let current_words: Vec<&str> = message_lower.split_whitespace().collect(); + for i in 0..current_words.len() { + let clean_curr = current_words[i].trim_matches(|c: char| !c.is_alphanumeric()); + if clean_curr == "not" || clean_curr == "no" { + if let Some(next_word) = current_words.get(i + 1) { + let clean_next = next_word.trim_matches(|c: char| !c.is_alphanumeric()); + if prev_words.contains(clean_next) { + return Some(CorrectionSignal::Negation { + negated_fragment: clean_next.to_string(), + }); + } + } } } } From b2cb4ea3dea09de2f9b9e8df348c18779107e200 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:08:00 -0400 Subject: [PATCH 53/59] implement core lib module with database debugging, file export, and rate-limiting utilities --- core/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index ff00046..ee37241 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -3639,10 +3639,10 @@ pub async fn test_helper_memory_extract_force( ) -> Result { use tauri::Manager; let app = tauri::test::mock_app(); - app.manage(DbState { + app.manage(AppState { db_path, redacted_session_key: std::sync::Mutex::new(None), }); - let state = app.state::(); + let state = app.state::(); memory_extract_force(provider, endpoint, model, state).await } From afd065f8c879b2444c252936d36f51a49953f36f Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:09:24 -0400 Subject: [PATCH 54/59] Removed clearNodesCache from the import list --- ui/components/ChatPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 962f6bd..e147c77 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -24,7 +24,7 @@ import { chatEditAndTruncate, type ChatMessage, } from "../services/chat"; -import { chatWithScope, getAllNodes, clearNodesCache } from "../services/nodes"; +import { chatWithScope, getAllNodes } from "../services/nodes"; import { getSetting } from "../services/settings"; import { listVaults } from "../services/vaults"; import { extractMemoryIfReady, extractMemoryForce } from "../services/memoryAgent"; @@ -365,7 +365,6 @@ function ChatPanel({ useEffect(() => { if (!visible) return; let active = true; - clearNodesCache(); getAllNodes(isRedactedUnlocked) .then((nodes) => { if (active) { From 1aacfa3c02f4706bc03fa013297ab0e7e12416ec Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:15:09 -0400 Subject: [PATCH 55/59] Changed the default context value in markdownUtils.tsx:169 to undefined (representing "no provider present") instead of null. Refactored useEffect Guard: Updated the guard inside WikiLinkBadge's mount check --- ui/utils/markdownUtils.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/utils/markdownUtils.tsx b/ui/utils/markdownUtils.tsx index 9c55398..81613da 100644 --- a/ui/utils/markdownUtils.tsx +++ b/ui/utils/markdownUtils.tsx @@ -166,7 +166,7 @@ export function preprocessMathDelimiters(text: string): string { processed = processed.replace(/\\\\\)/g, "$").replace(/\\\)/g, "$"); return processed; } -export const ExistingNodesContext = React.createContext | null>(null); +export const ExistingNodesContext = React.createContext | null | undefined>(undefined); function WikiLinkBadge({ nodeId, @@ -204,10 +204,10 @@ function WikiLinkBadge({ const isBroken = !isSearchQuery && nodeExists === false; const isLoading = nodeExists === null && !isSearchQuery; - // Validate node existence on mount (skip for search queries or if context provides it) + // Validate node existence on mount (skip for search queries or if context provider is present) React.useEffect(() => { - if (isSearchQuery || existingNodeIds) { - return; // Don't validate search queries or if existence set is provided + if (isSearchQuery || existingNodeIds !== undefined) { + return; // Don't validate search queries or if context provider is present } let isMounted = true; From d13a1afe9b4ecb3cbe21a42a0659fd488c1be24f Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:16:53 -0400 Subject: [PATCH 56/59] Reordered DB Queries: Modified trigger.rs:119-165 to query and check message_count as the first operation in should_extract_correction. Early Exit: If message_count < 3, the function immediately short-circuits and returns Ok(None). This avoids executing the heavier SQLite queries for the previous message, pending changeset items, and any negation/phrase scanning logic. --- core/src/memory_agent/trigger.rs | 36 ++++++++++++++------------------ 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index c6425ba..b2f82c0 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -121,7 +121,20 @@ pub fn should_extract_correction( session_id: &str, message: &str, ) -> Result, String> { - // 1. Query latest user message prior to this one in session + // 1. Check message count threshold (3) first to avoid redundant queries in early sessions + let message_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_messages WHERE session_id = ?1;", + [session_id], + |row| row.get(0), + ) + .map_err(|err| format!("Failed querying session message count: {err}"))?; + + if message_count < 3 { + return Ok(None); + } + + // 2. Query latest user message prior to this one in session let previous_message: Option = conn .query_row( "SELECT content FROM session_messages WHERE session_id = ?1 AND role = 'user' ORDER BY created_at DESC, rowid DESC LIMIT 1 OFFSET 1;", @@ -131,7 +144,7 @@ pub fn should_extract_correction( .optional() .map_err(|err| format!("Failed querying latest message: {err}"))?; - // 2. Query all pending changeset_items with status 'pending' for this session and extract their proposed_data column values + // 3. Query all pending changeset_items with status 'pending' for this session and extract their proposed_data column values let pending_data: Vec = conn .prepare( "SELECT ci.proposed_data \ @@ -148,24 +161,7 @@ pub fn should_extract_correction( let signal = correction::detect_correction_signal(message, previous_message.as_deref(), &pending_data); - if signal.is_none() { - return Ok(None); - } - - // 3. Check for signal detection and message count threshold (3) - let message_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM session_messages WHERE session_id = ?1;", - [session_id], - |row| row.get(0), - ) - .map_err(|err| format!("Failed querying session message count: {err}"))?; - - if message_count >= 3 { - Ok(signal) - } else { - Ok(None) - } + Ok(signal) } #[cfg(test)] From 9f6681826028efcbd71c5925b3291e4f057774e6 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:22:13 -0400 Subject: [PATCH 57/59] Started Transaction Early: In amendment.rs:122-160, we now start the SQLite Connection::transaction() at the very beginning of the amend_or_create_changeset function. Propagated Transaction Reference: Passed &tx (which derefs to &Connection) to find_pending_changeset(&tx, ...) and load_pending_items(&tx, ...). Simplified Flow: Removed the nested transaction blocks for creating fresh changesets (path 2a) and updating pending changesets (path 2b). All operations (reading the database for a pending changeset, building the changeset, persisting it, or updating matching changeset items) now run inside a single transaction and are fully atomic. --- core/src/memory_agent/amendment.rs | 32 +++++++++++------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs index 5bbecda..6cbe1d7 100644 --- a/core/src/memory_agent/amendment.rs +++ b/core/src/memory_agent/amendment.rs @@ -126,39 +126,31 @@ pub fn amend_or_create_changeset( model: &str, correction_signal: &crate::memory_agent::CorrectionSignal, ) -> Result<(String, bool), String> { + let tx = conn + .transaction() + .map_err(|err| format!("Failed to start transaction: {err}"))?; + // ── 1. Check for an existing pending changeset ──────────────────────────── - let existing_changeset = find_pending_changeset(conn, session_id, model)?; + let existing_changeset = find_pending_changeset(&tx, session_id, model)?; // ── 2a. No pending changeset — create a fresh one ──────────────────────── if existing_changeset.is_none() { - let changeset_id = { - let tx = conn - .transaction() - .map_err(|err| format!("Failed to start transaction: {err}"))?; - - let pending_changeset = - memory_agent::changeset::build_changeset(&tx, candidates, session_id)?; + let pending_changeset = + memory_agent::changeset::build_changeset(&tx, candidates, session_id)?; - let persisted_id = - memory_agent::persistence::persist_changeset(&tx, &pending_changeset, Some(model))?; + let persisted_id = + memory_agent::persistence::persist_changeset(&tx, &pending_changeset, Some(model))?; - tx.commit() - .map_err(|err| format!("Failed to commit transaction: {err}"))?; + tx.commit() + .map_err(|err| format!("Failed to commit transaction: {err}"))?; - persisted_id - }; - - return Ok((changeset_id, false)); + return Ok((persisted_id, false)); } // ── 2b. Pending changeset exists — amend in-place where possible ───────── let existing_id = existing_changeset.ok_or_else(|| "Pending changeset unexpectedly missing".to_string())?; - let tx = conn - .transaction() - .map_err(|err| format!("Failed to start transaction: {err}"))?; - // Load existing items once; all comparisons run against this snapshot. let pending_items = load_pending_items(&tx, &existing_id)?; From 64d75760e56795eab9fb1b643a4e5ebc8fa920a8 Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:23:29 -0400 Subject: [PATCH 58/59] Fixed the Debounce Bypass Bug: Updated lib.rs:683-698 to always retrieve the latest user message and scan it for a CorrectionSignal, regardless of the value returned by should_extract. Atomic/Isolated Flow: If a CorrectionSignal is detected, ready is set to true and the signal is correctly captured and propagated to the amendment pipeline. --- core/src/lib.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index ee37241..c3b545d 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -683,15 +683,17 @@ async fn memory_extract_if_ready( // 5. Check trigger let mut ready = memory_agent::trigger::should_extract(&conn, session_id)?; let mut correction_signal = None; - if !ready { - let latest_user_msg = fetch_latest_user_message(&conn, session_id)?; - if let Some(msg_content) = latest_user_msg { - let signal = - memory_agent::trigger::should_extract_correction(&conn, session_id, &msg_content)?; - if signal.is_some() { - ready = true; - correction_signal = signal; - } + + // Always scan the latest user message for a correction signal to ensure + // corrections are correctly routed to the amendment engine instead of creating duplicates, + // even if the standard extraction cooldown has already elapsed. + let latest_user_msg = fetch_latest_user_message(&conn, session_id)?; + if let Some(msg_content) = latest_user_msg { + let signal = + memory_agent::trigger::should_extract_correction(&conn, session_id, &msg_content)?; + if signal.is_some() { + ready = true; + correction_signal = signal; } } From 2e4a881b0fa37a41e442b23a5fa082205562f5eb Mon Sep 17 00:00:00 2001 From: Aashish Harishchandre Date: Mon, 15 Jun 2026 03:24:57 -0400 Subject: [PATCH 59/59] Fixed Context Provider in NodeEditorDetail.tsx: Updated NodeEditorDetail.tsx:277 to pass existingNodeIds directly to the ExistingNodesContext.Provider (removing the legacy ?? null default logic). --- ui/components/NodeEditorDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/components/NodeEditorDetail.tsx b/ui/components/NodeEditorDetail.tsx index 2cd952c..be68033 100644 --- a/ui/components/NodeEditorDetail.tsx +++ b/ui/components/NodeEditorDetail.tsx @@ -274,7 +274,7 @@ export default function NodeEditorDetail({ ) ) : ( - +