diff --git a/.changeset/memory-management-commands.md b/.changeset/memory-management-commands.md new file mode 100644 index 000000000..31dc4409f --- /dev/null +++ b/.changeset/memory-management-commands.md @@ -0,0 +1,13 @@ +--- +'@dexto/agent-management': patch +'dexto': minor +--- + +Add interactive memory management commands to CLI: + +- New `# ` command to add memory entries to agent instruction files (AGENTS.md, CLAUDE.md, or GEMINI.md) +- New `/memory` command to view current memory file path +- New `/memory list` command to list all memory entries +- New `/memory remove ` command to remove specific memory entries +- Memory entries are stored in a `## Memory` section within the instruction file + diff --git a/packages/agent-management/src/config/index.ts b/packages/agent-management/src/config/index.ts index 6ba0f8d28..aa7939b8f 100644 --- a/packages/agent-management/src/config/index.ts +++ b/packages/agent-management/src/config/index.ts @@ -17,6 +17,7 @@ export { enrichAgentConfig, deriveAgentId, discoverCommandPrompts, + discoverAgentInstructionFile, type EnrichAgentConfigOptions, } from './config-enrichment.js'; export { ConfigError } from './errors.js'; diff --git a/packages/agent-management/src/index.ts b/packages/agent-management/src/index.ts index 0c7aa1d57..adac6441a 100644 --- a/packages/agent-management/src/index.ts +++ b/packages/agent-management/src/index.ts @@ -85,6 +85,7 @@ export { loadAgentConfig, enrichAgentConfig, deriveAgentId, + discoverAgentInstructionFile, addPromptToAgentConfig, removePromptFromAgentConfig, deletePromptByMetadata, diff --git a/packages/cli/src/cli/commands/interactive-commands/commands.ts b/packages/cli/src/cli/commands/interactive-commands/commands.ts index c7a81e341..92011b0ab 100644 --- a/packages/cli/src/cli/commands/interactive-commands/commands.ts +++ b/packages/cli/src/cli/commands/interactive-commands/commands.ts @@ -26,6 +26,7 @@ import { isDextoAuthEnabled } from '@dexto/agent-management'; // Import modular command definitions import { generalCommands, createHelpCommand } from './general-commands.js'; +import { memoryCommand, handleMemoryAdd } from './memory-command.js'; import { searchCommand, resumeCommand, renameCommand } from './session/index.js'; import { exportCommand } from './export/index.js'; import { modelCommands } from './model/index.js'; @@ -60,6 +61,9 @@ const baseCommands: CommandDefinition[] = [ // General commands (without help) ...generalCommands, + // Memory command - show loaded memory file + memoryCommand, // /memory - show agent memory file loaded to context + // Session management searchCommand, // /search - opens search overlay resumeCommand, // /resume - opens session selector overlay @@ -112,6 +116,12 @@ export async function executeCommand( // Create command context with sessionId const ctx = { sessionId: sessionId ?? null }; + // Handle memory-add command (triggered by # prefix) + if (command === 'memory-add') { + const content = args[0] ?? ''; + return await handleMemoryAdd(content); + } + // Find the command (including aliases) const cmd = CLI_COMMANDS.find( (c) => c.name === command || (c.aliases && c.aliases.includes(command)) diff --git a/packages/cli/src/cli/commands/interactive-commands/memory-command.ts b/packages/cli/src/cli/commands/interactive-commands/memory-command.ts new file mode 100644 index 000000000..60dc5f6f7 --- /dev/null +++ b/packages/cli/src/cli/commands/interactive-commands/memory-command.ts @@ -0,0 +1,170 @@ +import chalk from 'chalk'; +import type { DextoAgent } from '@dexto/core'; +import type { CommandDefinition, CommandHandlerResult, CommandContext } from './command-parser.js'; +import { formatForInkCli } from './utils/format-output.js'; +import { addMemoryEntry, listMemoryEntries, removeMemoryEntry } from './memory-utils.js'; + +/** + * Handler for /memory show (shows both project and global) + */ +async function handleShowCommand(): Promise { + const { project, global } = listMemoryEntries(); + const lines: string[] = []; + + lines.push(chalk.bold('\nšŸ“ Memory Entries:\n')); + + // Global Section + lines.push(chalk.bold.magenta('User Memory (Global):')); + if (global.filePath) { + lines.push(chalk.dim(` Path: ${global.filePath}`)); + } + if (global.entries.length === 0) { + lines.push(chalk.dim(' (No entries yet)')); + } else { + global.entries.forEach((entry, index) => { + lines.push(chalk.cyan(` ${index + 1}. `) + entry); + }); + } + lines.push(''); + + // Project Section + lines.push(chalk.bold.cyan('Project Memory:')); + if (project.filePath) { + lines.push(chalk.dim(` Path: ${project.filePath}`)); + } + if (project.entries.length === 0) { + lines.push(chalk.dim(' (No entries yet)')); + } else { + project.entries.forEach((entry, index) => { + lines.push(chalk.cyan(` ${index + 1}. `) + entry); + }); + } + + lines.push(chalk.dim('\nQuick remove:')); + lines.push(chalk.dim(' /memory remove # Remove from project')); + lines.push(chalk.dim(' /memory remove --global # Remove from global')); + lines.push(chalk.dim(' /memory remove global # Remove from global')); + lines.push(chalk.dim('\nOr use: /memory remove (interactive wizard)')); + + return formatForInkCli(lines.join('\n')); +} + +/** + * Handler for /memory remove with power user shortcuts + * Syntax: + * /memory remove → Interactive wizard + * /memory remove → Remove from project + * /memory remove --global → Remove from global + * /memory remove global → Remove from global + */ +async function handleRemoveCommand(args: string[]): Promise { + // No args → trigger wizard + if (args.length === 0) { + return { + __triggerOverlay: 'memory-remove-wizard', + } as any; + } + + // Parse arguments + let scope: 'project' | 'global' = 'project'; + let indexStr: string | undefined; + + // Check for "global" as first arg: /memory remove global 3 + if (args[0] === 'global') { + scope = 'global'; + indexStr = args[1]; + } else { + indexStr = args[0]; + // Check for --global flag: /memory remove 3 --global + if (args.includes('--global')) { + scope = 'global'; + } + } + + // Validate index + if (!indexStr) { + return formatForInkCli(chalk.red('\nāŒ Missing entry number')); + } + + const index = parseInt(indexStr, 10) - 1; // Convert to 0-based index + + if (isNaN(index)) { + return formatForInkCli(chalk.red('\nāŒ Invalid entry number')); + } + + // Remove the entry + const result = removeMemoryEntry(index, scope); + + if (result.success) { + const scopeLabel = scope === 'global' ? 'User (global)' : 'Project'; + return formatForInkCli( + chalk.green(`\nāœ“ ${scopeLabel} memory entry removed`) + + chalk.dim(`\nFile: ${result.filePath}`) + ); + } else { + return formatForInkCli(chalk.red(`\nāŒ Failed to remove entry: ${result.error}`)); + } +} + +/** + * Handler for # - DEPRECATED: Now handled via /memory add + * This remains for internal use if needed but prefix # is removed from parser. + */ +export async function handleMemoryAdd( + content: string, + scope: 'project' | 'global' = 'project' +): Promise { + if (!content || content.trim() === '') { + return formatForInkCli(chalk.yellow('\n⚠ No content provided')); + } + + const result = addMemoryEntry(content, scope); + + if (result.success) { + return formatForInkCli( + chalk.green(`\nāœ“ ${scope === 'global' ? 'Global' : 'Project'} memory entry added`) + + chalk.dim(`\nFile: ${result.filePath}\n`) + + chalk.dim('View all entries with: /memory show') + ); + } else { + return formatForInkCli(chalk.red(`\nāŒ Failed to add memory: ${result.error}`)); + } +} + +export const memoryCommand: CommandDefinition = { + name: 'memory', + description: 'Manage agent memory (interactive menu)', + usage: '/memory [show|add|remove [] [--global]]', + category: 'General', + aliases: ['mem'], + handler: async ( + args: string[], + _agent: DextoAgent, + _ctx: CommandContext + ): Promise => { + const subcommand = args[0]?.toLowerCase(); + + // Handle subcommands + if (subcommand === 'show') { + return handleShowCommand(); + } + + if (subcommand === 'remove' || subcommand === 'rm') { + return handleRemoveCommand(args.slice(1)); + } + + if (subcommand === 'add') { + // If argument is provided, we can jump straight to scope selection (handled by wizard) + // But for now, just trigger the overlay which handles everything + return { + __triggerOverlay: 'memory-add-wizard', + args: args.slice(1), + } as any; + } + + // Default: trigger interactive MemoryManager overlay + return { + __triggerOverlay: 'memory-manager', + } as any; + }, +}; diff --git a/packages/cli/src/cli/commands/interactive-commands/memory-utils.ts b/packages/cli/src/cli/commands/interactive-commands/memory-utils.ts new file mode 100644 index 000000000..fa923e2dd --- /dev/null +++ b/packages/cli/src/cli/commands/interactive-commands/memory-utils.ts @@ -0,0 +1,282 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import * as path from 'path'; +import { discoverAgentInstructionFile } from '@dexto/agent-management'; +import { getDextoGlobalPath } from '@dexto/core'; + +/** + * Memory section header in the instruction file + */ +const MEMORY_SECTION_HEADER = '## Memory'; + +/** + * Discovers the instruction file in current directory. + */ +function findProjectInstructionFile(): string | null { + return discoverAgentInstructionFile(); +} + +/** + * Gets the global instruction file path (~/.dexto/AGENTS.md) + */ +function getGlobalInstructionFilePath(): string { + return getDextoGlobalPath('', 'AGENTS.md'); +} + +/** + * Gets or creates the instruction file for a given scope + */ +function getOrCreateInstructionFile(scope: 'project' | 'global'): string { + if (scope === 'global') { + const filePath = getGlobalInstructionFilePath(); + if (existsSync(filePath)) return filePath; + + // Ensure ~/.dexto directory exists + const dir = path.dirname(filePath); + mkdirSync(dir, { recursive: true }); + + // Create AGENTS.md with Memory section + const initialContent = `${MEMORY_SECTION_HEADER}\n`; + writeFileSync(filePath, initialContent, 'utf-8'); + return filePath; + } + + const existing = findProjectInstructionFile(); + if (existing) { + return existing; + } + + // Create AGENTS.md in current directory + const filePath = path.join(process.cwd(), 'AGENTS.md'); + const initialContent = `${MEMORY_SECTION_HEADER}\n`; + writeFileSync(filePath, initialContent, 'utf-8'); + return filePath; +} + +/** + * Parse memory entries from file content + */ +function parseMemoryEntries(content: string): string[] { + const lines = content.split('\n'); + const entries: string[] = []; + let inMemorySection = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Check for memory section header + if (trimmed === MEMORY_SECTION_HEADER) { + inMemorySection = true; + continue; + } + + // Check for next section (any ## header) + if (inMemorySection && trimmed.startsWith('##')) { + break; + } + + // Collect bullet points in memory section + if (inMemorySection && trimmed.startsWith('-')) { + const entry = trimmed.slice(1).trim(); + if (entry) { + entries.push(entry); + } + } + } + + return entries; +} + +/** + * Add a memory entry to the instruction file for a given scope + */ +export function addMemoryEntry( + content: string, + scope: 'project' | 'global' = 'project' +): { + success: boolean; + filePath: string; + error?: string; +} { + try { + if (!content || content.trim() === '') { + return { + success: false, + filePath: '', + error: 'Content cannot be empty', + }; + } + + const filePath = getOrCreateInstructionFile(scope); + let fileContent = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : ''; + + // Check if memory section exists + if (!fileContent.includes(MEMORY_SECTION_HEADER)) { + // Add memory section at the end + if (!fileContent.endsWith('\n\n')) { + fileContent += fileContent.endsWith('\n') ? '\n' : '\n\n'; + } + fileContent += `${MEMORY_SECTION_HEADER}\n`; + } + + // Find the memory section and add the entry + const lines = fileContent.split('\n'); + const newLines: string[] = []; + let inMemorySection = false; + let memorySectionEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ''; + const trimmed = line.trim(); + + if (trimmed === MEMORY_SECTION_HEADER) { + inMemorySection = true; + newLines.push(line); + continue; + } + + // Track where memory section ends + if (inMemorySection && trimmed.startsWith('##')) { + inMemorySection = false; + memorySectionEnd = i; + } + + newLines.push(line); + } + + // Add the new entry + const newEntry = `- ${content.trim()}`; + + if (memorySectionEnd !== -1) { + // Insert before next section + newLines.splice(memorySectionEnd, 0, newEntry); + } else { + // Add at the end + newLines.push(newEntry); + } + + writeFileSync(filePath, newLines.join('\n'), 'utf-8'); + + return { success: true, filePath }; + } catch (error) { + return { + success: false, + filePath: '', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export interface MemoryListResult { + project: { entries: string[]; filePath: string | null }; + global: { entries: string[]; filePath: string | null }; +} + +/** + * List all memory entries for both project and global scopes + */ +export function listMemoryEntries(): MemoryListResult { + const projectPath = findProjectInstructionFile(); + const globalPath = getGlobalInstructionFilePath(); + + const result: MemoryListResult = { + project: { entries: [], filePath: projectPath || path.join(process.cwd(), 'AGENTS.md') }, + global: { entries: [], filePath: globalPath }, + }; + + if (projectPath && existsSync(projectPath)) { + const content = readFileSync(projectPath, 'utf-8'); + result.project.entries = parseMemoryEntries(content); + } + + if (existsSync(globalPath)) { + const content = readFileSync(globalPath, 'utf-8'); + result.global.entries = parseMemoryEntries(content); + } + + return result; +} + +/** + * Remove a memory entry by index (0-based) and scope + */ +export function removeMemoryEntry( + index: number, + scope: 'project' | 'global' = 'project' +): { + success: boolean; + filePath: string; + error?: string; +} { + try { + const filePath = + scope === 'global' ? getGlobalInstructionFilePath() : findProjectInstructionFile(); + + if (!filePath || !existsSync(filePath)) { + return { success: false, filePath: '', error: `No ${scope} instruction file found` }; + } + + const content = readFileSync(filePath, 'utf-8'); + const entries = parseMemoryEntries(content); + + if (index < 0 || index >= entries.length) { + return { success: false, filePath, error: 'Invalid entry index' }; + } + + // Remove the entry + const entryToRemove = entries[index]; + if (!entryToRemove) { + return { success: false, filePath, error: 'Entry not found' }; + } + + const lines = content.split('\n'); + const newLines: string[] = []; + let inMemorySection = false; + let removed = false; + + // Regex to extract bullet content: matches "- content" with any whitespace + const bulletRegex = /^\s*-\s*(.+)$/; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === MEMORY_SECTION_HEADER) { + inMemorySection = true; + newLines.push(line); + continue; + } + + if (inMemorySection && trimmed.startsWith('##')) { + inMemorySection = false; + } + + // Check if this line is a bullet point that matches the entry to remove + if (inMemorySection && !removed) { + const match = trimmed.match(bulletRegex); + if (match) { + const bulletContent = match[1]?.trim(); + if (bulletContent === entryToRemove.trim()) { + removed = true; + continue; // Skip this line + } + } + } + + newLines.push(line); + } + + // Only write to file if we actually removed something + if (!removed) { + return { success: false, filePath, error: 'Entry not found' }; + } + + writeFileSync(filePath, newLines.join('\n'), 'utf-8'); + + return { success: true, filePath }; + } catch (error) { + return { + success: false, + filePath: '', + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/packages/cli/src/cli/ink-cli/components/overlays/MemoryAddWizard.tsx b/packages/cli/src/cli/ink-cli/components/overlays/MemoryAddWizard.tsx new file mode 100644 index 000000000..a585c4243 --- /dev/null +++ b/packages/cli/src/cli/ink-cli/components/overlays/MemoryAddWizard.tsx @@ -0,0 +1,189 @@ +/** + * MemoryAddWizard Component + * Interactive overlay for adding new memory entries (Scope -> Content) + */ + +import React, { + useState, + useEffect, + forwardRef, + useRef, + useImperativeHandle, + useCallback, +} from 'react'; +import { Box, Text } from 'ink'; +import type { Key } from '../../hooks/useInputOrchestrator.js'; +import { BaseSelector, type BaseSelectorHandle } from '../base/BaseSelector.js'; +import type { MemoryAddScope, MemoryAddWizardState } from '../../state/types.js'; + +interface MemoryAddWizardProps { + isVisible: boolean; + state: MemoryAddWizardState | null; + onUpdateState: (updates: Partial) => void; + onComplete: (content: string, scope: MemoryAddScope) => void; + onClose: () => void; +} + +export interface MemoryAddWizardHandle { + handleInput: (input: string, key: Key) => boolean; +} + +interface ScopeOption { + scope: MemoryAddScope; + label: string; + hint: string; +} + +const SCOPE_OPTIONS: ScopeOption[] = [ + { + scope: 'project', + label: 'Project Memory', + hint: 'Specific to this workspace (./AGENTS.md)', + }, + { + scope: 'global', + label: 'User Memory', + hint: 'Global instructions for all projects (~/.dexto/AGENTS.md)', + }, +]; + +/** + * Memory add wizard overlay - guides user through adding a memory entry + */ +const MemoryAddWizard = forwardRef( + function MemoryAddWizard({ isVisible, state, onUpdateState, onComplete, onClose }, ref) { + const baseSelectorRef = useRef(null); + + // Reset state when becoming visible + useEffect(() => { + if (isVisible && !state) { + onUpdateState({ step: 'scope', scope: null, content: '' }); + } + }, [isVisible, state, onUpdateState]); + + const handleScopeSelect = (option: ScopeOption) => { + onUpdateState({ step: 'content', scope: option.scope }); + }; + + const handleContentSubmit = useCallback(() => { + if (!state?.content.trim() || !state.scope) return; + onComplete(state.content, state.scope); + }, [state, onComplete]); + + // Forward handleInput + useImperativeHandle( + ref, + () => ({ + handleInput: (input: string, key: Key): boolean => { + if (!isVisible || !state) return false; + + // Step 1: Scope Selection (uses BaseSelector) + if (state.step === 'scope') { + return baseSelectorRef.current?.handleInput(input, key) ?? false; + } + + // Step 2: Content Input + if (state.step === 'content') { + // Escape to go back to scope selection or close + if (key.escape) { + onUpdateState({ step: 'scope', scope: null }); + return true; + } + + // Enter to complete + if (key.return) { + handleContentSubmit(); + return true; + } + + // Backspace + if (key.backspace || key.delete) { + onUpdateState({ content: state.content.slice(0, -1) }); + return true; + } + + // Regular character input + if (input && !key.ctrl && !key.meta) { + onUpdateState({ content: state.content + input }); + return true; + } + } + + return false; + }, + }), + [isVisible, state, onUpdateState, handleContentSubmit] + ); + + if (!isVisible || !state) return null; + + // Render Step 1: Scope Selection + if (state.step === 'scope') { + const formatItem = (option: ScopeOption, isSelected: boolean) => ( + + {isSelected ? 'ā–ø ' : ' '} + + {option.label} + + + {' — '} + {option.hint} + + + ); + + return ( + + onUpdateState({ scope: SCOPE_OPTIONS[idx]?.scope ?? null }) + } + onSelect={handleScopeSelect} + onClose={onClose} + formatItem={formatItem} + title="Select Memory Scope" + borderColor="cyan" + emptyMessage="No options available" + /> + ); + } + + // Render Step 2: Content Input + return ( + + + + Add {state.scope === 'global' ? 'User' : 'Project'} Memory + + + + + Enter the memory content for the AI agent: + + + + > + {state.content} + _ + + + + Enter to save • Esc to go back + + + ); + } +); + +export default MemoryAddWizard; diff --git a/packages/cli/src/cli/ink-cli/components/overlays/MemoryManager.tsx b/packages/cli/src/cli/ink-cli/components/overlays/MemoryManager.tsx new file mode 100644 index 000000000..a022af8e9 --- /dev/null +++ b/packages/cli/src/cli/ink-cli/components/overlays/MemoryManager.tsx @@ -0,0 +1,134 @@ +/** + * MemoryManager Component + * Main menu for memory management + */ + +import React, { useState, useEffect, forwardRef, useRef, useImperativeHandle } from 'react'; +import { Box, Text } from 'ink'; +import type { Key } from '../../hooks/useInputOrchestrator.js'; +import { BaseSelector, type BaseSelectorHandle } from '../base/BaseSelector.js'; + +export type MemoryAction = 'show' | 'add' | 'remove' | 'back'; + +interface MemoryManagerProps { + isVisible: boolean; + onAction: (action: MemoryAction) => void; + onClose: () => void; +} + +export interface MemoryManagerHandle { + handleInput: (input: string, key: Key) => boolean; +} + +interface MemoryOption { + action: MemoryAction; + label: string; + hint: string; + icon: string; +} + +const MEMORY_OPTIONS: MemoryOption[] = [ + { + action: 'show', + label: 'Show Memory', + hint: 'View project and global entries', + icon: 'šŸ“', + }, + { + action: 'add', + label: 'Add Memory', + hint: 'Add new project or global entry', + icon: 'āž•', + }, + { + action: 'remove', + label: 'Remove Memory', + hint: 'Remove an existing entry', + icon: 'šŸ—‘ļø', + }, + { + action: 'back', + label: 'Back', + hint: '', + icon: '←', + }, +]; + +/** + * Memory manager overlay - main menu for memory management + */ +const MemoryManager = forwardRef(function MemoryManager( + { isVisible, onAction, onClose }, + ref +) { + const baseSelectorRef = useRef(null); + + // Forward handleInput to BaseSelector + useImperativeHandle( + ref, + () => ({ + handleInput: (input: string, key: Key): boolean => { + return baseSelectorRef.current?.handleInput(input, key) ?? false; + }, + }), + [] + ); + + const [selectedIndex, setSelectedIndex] = useState(0); + + // Reset selection when becoming visible + useEffect(() => { + if (isVisible) { + setSelectedIndex(0); + } + }, [isVisible]); + + // Format option for display + const formatItem = (option: MemoryOption, isSelected: boolean) => { + const isBack = option.action === 'back'; + + return ( + + {isSelected ? 'ā–ø ' : ' '} + {option.icon} + + {option.label} + + {option.hint && ( + + {' '} + — {option.hint} + + )} + + ); + }; + + // Handle selection + const handleSelect = (option: MemoryOption) => { + if (option.action === 'back') { + onClose(); + } else { + onAction(option.action); + } + }; + + return ( + + ); +}); + +export default MemoryManager; diff --git a/packages/cli/src/cli/ink-cli/components/overlays/MemoryRemoveWizard.tsx b/packages/cli/src/cli/ink-cli/components/overlays/MemoryRemoveWizard.tsx new file mode 100644 index 000000000..cc4966c9d --- /dev/null +++ b/packages/cli/src/cli/ink-cli/components/overlays/MemoryRemoveWizard.tsx @@ -0,0 +1,173 @@ +/** + * MemoryRemoveWizard Component + * Interactive overlay for removing memory entries (Scope -> Selection) + */ + +import React, { useState, useEffect, forwardRef, useRef, useImperativeHandle } from 'react'; +import { Box, Text } from 'ink'; +import type { Key } from '../../hooks/useInputOrchestrator.js'; +import { BaseSelector, type BaseSelectorHandle } from '../base/BaseSelector.js'; +import type { MemoryAddScope, MemoryRemoveWizardState } from '../../state/types.js'; + +interface MemoryRemoveWizardProps { + isVisible: boolean; + state: MemoryRemoveWizardState | null; + projectEntries: string[]; + globalEntries: string[]; + onUpdateState: (updates: Partial) => void; + onComplete: (index: number, scope: MemoryAddScope) => void; + onClose: () => void; +} + +export interface MemoryRemoveWizardHandle { + handleInput: (input: string, key: Key) => boolean; +} + +interface ScopeOption { + scope: MemoryAddScope; + label: string; + hint: string; +} + +const SCOPE_OPTIONS: ScopeOption[] = [ + { + scope: 'project', + label: 'Project Memory', + hint: 'Remove from this workspace (./AGENTS.md)', + }, + { + scope: 'global', + label: 'User Memory', + hint: 'Remove from global instructions (~/.dexto/AGENTS.md)', + }, +]; + +/** + * Memory remove wizard overlay - guides user through removing a memory entry + */ +const MemoryRemoveWizard = forwardRef( + function MemoryRemoveWizard( + { isVisible, state, projectEntries, globalEntries, onUpdateState, onComplete, onClose }, + ref + ) { + const scopeSelectorRef = useRef(null); + const entrySelectorRef = useRef(null); + const [entryIndex, setEntryIndex] = useState(0); + + // Reset state when becoming visible + useEffect(() => { + if (isVisible && !state) { + onUpdateState({ step: 'scope', scope: null }); + } + if (state?.step === 'selection') { + setEntryIndex(0); + } + }, [isVisible, state?.step, onUpdateState]); + + const handleScopeSelect = (option: ScopeOption) => { + onUpdateState({ step: 'selection', scope: option.scope }); + }; + + const handleEntrySelect = (entry: string) => { + if (!state?.scope) return; + onComplete(entryIndex, state.scope); + }; + + // Forward handleInput + useImperativeHandle( + ref, + () => ({ + handleInput: (input: string, key: Key): boolean => { + if (!isVisible || !state) return false; + + // Step 1: Scope Selection + if (state.step === 'scope') { + return scopeSelectorRef.current?.handleInput(input, key) ?? false; + } + + // Step 2: Entry Selection + if (state.step === 'selection') { + // Escape to go back to scope selection + if (key.escape) { + onUpdateState({ step: 'scope', scope: null }); + return true; + } + + return entrySelectorRef.current?.handleInput(input, key) ?? false; + } + + return false; + }, + }), + [isVisible, state, onUpdateState] + ); + + if (!isVisible || !state) return null; + + // Render Step 1: Scope Selection + if (state.step === 'scope') { + const formatItem = (option: ScopeOption, isSelected: boolean) => ( + + {isSelected ? 'ā–ø ' : ' '} + + {option.label} + + + {' — '} + {option.hint} + + + ); + + return ( + + onUpdateState({ scope: SCOPE_OPTIONS[idx]?.scope ?? null }) + } + onSelect={handleScopeSelect} + onClose={onClose} + formatItem={formatItem} + title="Select Scope to Remove From" + borderColor="red" + emptyMessage="No options available" + /> + ); + } + + // Render Step 2: Entry Selection + const entries = state.scope === 'global' ? globalEntries : projectEntries; + + const formatEntry = (entry: string, isSelected: boolean) => ( + + {isSelected ? 'Ɨ ' : ' '} + + {entry.length > 80 ? entry.slice(0, 77) + '...' : entry} + + + ); + + return ( + onUpdateState({ step: 'scope', scope: null })} + formatItem={(entry, isSelected) => formatEntry(entry as string, isSelected)} + title={`Select Entry to Remove (${state.scope === 'global' ? 'User' : 'Project'})`} + borderColor="red" + emptyMessage={`No ${state.scope} memory entries found`} + /> + ); + } +); + +export default MemoryRemoveWizard; diff --git a/packages/cli/src/cli/ink-cli/containers/InputContainer.tsx b/packages/cli/src/cli/ink-cli/containers/InputContainer.tsx index 0a67ece15..fe62a7d41 100644 --- a/packages/cli/src/cli/ink-cli/containers/InputContainer.tsx +++ b/packages/cli/src/cli/ink-cli/containers/InputContainer.tsx @@ -19,6 +19,7 @@ import type { PendingImage, PastedBlock, TodoItem, + OverlayType, } from '../state/types.js'; import { createUserMessage } from '../utils/messageFormatting.js'; import { generateMessageId } from '../utils/idGenerator.js'; @@ -467,6 +468,34 @@ export const InputContainer = forwardRef { + const updates: Partial = { + isProcessing: false, + activeOverlay: overlayType, + }; + + // Special handling for memory-add-wizard to initialize its state from command args + if (overlayType === 'memory-add-wizard') { + updates.memoryAddWizard = { + step: 'scope', + scope: null, + content: args.join(' '), + }; + } + + return { ...prev, ...updates }; + }); + + buffer.setText(''); + setInput((prev) => ({ ...prev, images: [], pastedBlocks: [] })); + return; + } + if (result.type === 'output' && result.output) { const output = result.output; setMessages((prev) => [ @@ -760,6 +789,9 @@ export const InputContainer = forwardRef(null); const marketplaceAddPromptRef = useRef(null); + const memoryManagerRef = useRef(null); + const memoryAddWizardRef = useRef(null); + const memoryRemoveWizardRef = useRef(null); // Expose handleInput method via ref - routes to appropriate overlay useImperativeHandle( @@ -291,6 +310,14 @@ export const OverlayContainer = forwardRef ({ ...prev, activeOverlay: 'none' })); }, [setUi]); + // Handle MemoryManager actions + const handleMemoryManagerAction = useCallback( + (action: MemoryAction) => { + switch (action) { + case 'show': + setUi((prev) => ({ ...prev, activeOverlay: 'none' })); + // Execute /memory show command + void onSubmitPromptCommand?.('/memory show'); + break; + case 'add': + setUi((prev) => ({ + ...prev, + activeOverlay: 'memory-add-wizard', + memoryAddWizard: { step: 'scope', scope: null, content: '' }, + })); + break; + case 'remove': + setUi((prev) => ({ + ...prev, + activeOverlay: 'memory-remove-wizard', + memoryRemoveWizard: { step: 'scope', scope: null }, + })); + break; + default: + setUi((prev) => ({ ...prev, activeOverlay: 'none' })); + break; + } + }, + [setUi, onSubmitPromptCommand] + ); + + // Handle MemoryAddWizard state updates + const handleMemoryWizardUpdate = useCallback( + (updates: Partial) => { + setUi((prev) => ({ + ...prev, + memoryAddWizard: prev.memoryAddWizard + ? { ...prev.memoryAddWizard, ...updates } + : null, + })); + }, + [setUi] + ); + + // Handle MemoryAddWizard completion + const handleMemoryAddComplete = useCallback( + async (content: string, scope: MemoryAddScope) => { + setUi((prev) => ({ ...prev, activeOverlay: 'none', memoryAddWizard: null })); + + try { + const { addMemoryEntry } = await import( + '../../commands/interactive-commands/memory-utils.js' + ); + const result = addMemoryEntry(content, scope); + + if (result.success) { + setMessages((prev) => [ + ...prev, + { + id: generateMessageId('system'), + role: 'system', + content: `āœ… ${scope === 'global' ? 'Global' : 'Project'} memory entry added!`, + timestamp: new Date(), + }, + ]); + } else { + throw new Error(result.error || 'Unknown error'); + } + } catch (error) { + setMessages((prev) => [ + ...prev, + { + id: generateMessageId('error'), + role: 'system', + content: `āŒ Failed to add memory: ${error instanceof Error ? error.message : String(error)}`, + timestamp: new Date(), + }, + ]); + } + }, + [setUi, setMessages] + ); + + // Handle MemoryRemoveWizard state updates + const handleMemoryRemoveWizardUpdate = useCallback( + (updates: Partial) => { + setUi((prev) => ({ + ...prev, + memoryRemoveWizard: prev.memoryRemoveWizard + ? { ...prev.memoryRemoveWizard, ...updates } + : null, + })); + }, + [setUi] + ); + + // Handle MemoryRemoveWizard completion + const handleMemoryRemoveComplete = useCallback( + async (index: number, scope: MemoryAddScope) => { + setUi((prev) => ({ ...prev, activeOverlay: 'none', memoryRemoveWizard: null })); + + try { + const { removeMemoryEntry } = await import( + '../../commands/interactive-commands/memory-utils.js' + ); + const result = removeMemoryEntry(index, scope); + + if (result.success) { + const scopeLabel = scope === 'global' ? 'User (global)' : 'Project'; + setMessages((prev) => [ + ...prev, + { + id: generateMessageId('system'), + role: 'system', + content: `āœ… ${scopeLabel} memory entry removed!`, + timestamp: new Date(), + }, + ]); + } else { + throw new Error(result.error || 'Unknown error'); + } + } catch (error) { + setMessages((prev) => [ + ...prev, + { + id: generateMessageId('error'), + role: 'system', + content: `āŒ Failed to remove memory: ${error instanceof Error ? error.message : String(error)}`, + timestamp: new Date(), + }, + ]); + } + }, + [setUi, setMessages] + ); + return ( <> {/* Approval prompt */} @@ -2507,6 +2670,44 @@ export const OverlayContainer = forwardRef )} + + {/* Memory Manager */} + {ui.activeOverlay === 'memory-manager' && ( + + + + )} + + {/* Memory Add Wizard */} + {ui.activeOverlay === 'memory-add-wizard' && ( + + )} + + {/* Memory Remove Wizard */} + {ui.activeOverlay === 'memory-remove-wizard' && ( + + )} ); } diff --git a/packages/cli/src/cli/ink-cli/hooks/useCLIState.ts b/packages/cli/src/cli/ink-cli/hooks/useCLIState.ts index 830f2b858..2696a14a4 100644 --- a/packages/cli/src/cli/ink-cli/hooks/useCLIState.ts +++ b/packages/cli/src/cli/ink-cli/hooks/useCLIState.ts @@ -128,6 +128,8 @@ export function useCLIState({ todoExpanded: true, // Default to expanded to show full todo list planModeActive: false, planModeInitialized: false, + memoryAddWizard: null, + memoryRemoveWizard: null, }); // Input state diff --git a/packages/cli/src/cli/ink-cli/state/initialState.ts b/packages/cli/src/cli/ink-cli/state/initialState.ts index 02078ce1d..f38ce882b 100644 --- a/packages/cli/src/cli/ink-cli/state/initialState.ts +++ b/packages/cli/src/cli/ink-cli/state/initialState.ts @@ -45,6 +45,8 @@ export function createInitialState(initialModelName: string = ''): CLIState { todoExpanded: true, planModeActive: false, planModeInitialized: false, + memoryAddWizard: null, + memoryRemoveWizard: null, }, session: { id: null, diff --git a/packages/cli/src/cli/ink-cli/state/types.ts b/packages/cli/src/cli/ink-cli/state/types.ts index 964b8260c..2d28b4399 100644 --- a/packages/cli/src/cli/ink-cli/state/types.ts +++ b/packages/cli/src/cli/ink-cli/state/types.ts @@ -343,7 +343,10 @@ export type OverlayType = | 'plugin-list' | 'plugin-actions' | 'marketplace-browser' - | 'marketplace-add'; + | 'marketplace-add' + | 'memory-manager' + | 'memory-add-wizard' + | 'memory-remove-wizard'; /** * MCP server type for custom wizard (null = not yet selected) @@ -383,6 +386,25 @@ export interface PromptAddWizardState { content: string; } +/** + * Memory add wizard state + */ +export type MemoryAddScope = 'project' | 'global'; + +export interface MemoryAddWizardState { + step: 'scope' | 'content'; + scope: MemoryAddScope | null; + content: string; +} + +/** + * Memory remove wizard state + */ +export interface MemoryRemoveWizardState { + step: 'scope' | 'selection'; + scope: MemoryAddScope | null; +} + /** * History search state (Ctrl+R reverse search) */ @@ -415,7 +437,9 @@ export interface UIState { todoExpanded: boolean; // True when todo list is expanded (shows all tasks), false when collapsed (shows current task only) // Plan mode state (Shift+Tab toggle) planModeActive: boolean; // True when plan mode indicator is shown - planModeInitialized: boolean; // True after first message sent in plan mode (prevents re-injection) + planModeInitialized: boolean; // True when plan tools have been injected once + memoryAddWizard: MemoryAddWizardState | null; // Memory add wizard state + memoryRemoveWizard: MemoryRemoveWizardState | null; // Memory remove wizard state } /** diff --git a/packages/cli/src/cli/ink-cli/utils/commandOverlays.ts b/packages/cli/src/cli/ink-cli/utils/commandOverlays.ts index a7025ba34..87563910a 100644 --- a/packages/cli/src/cli/ink-cli/utils/commandOverlays.ts +++ b/packages/cli/src/cli/ink-cli/utils/commandOverlays.ts @@ -36,6 +36,7 @@ const NO_ARGS_OVERLAY: Record = { session: 'session-subcommand-selector', log: 'log-level-selector', prompts: 'prompt-list', + memory: 'memory-manager', }; /**