diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx
index 77f039ae..6d5b9410 100644
--- a/src/components/Chat/Chat.test.tsx
+++ b/src/components/Chat/Chat.test.tsx
@@ -3,7 +3,7 @@ import { Text } from 'ink';
import { prewarmCodeBlocks } from '@/components/CodeBlock';
import { DECISION, MODE, PROMPT, THEME } from '@/constants';
import type { Decision, ToolResult } from '@/types';
-import { ollama, time, tools } from '@/utils';
+import { agents, memory, ollama, time, tools } from '@/utils';
import { renderWithTheme } from '@/utils/testing';
const mockState = vi.hoisted(() => ({
@@ -58,6 +58,15 @@ const toolMocks = vi.hoisted(() => ({
executeTool: vi.fn(),
runShell: vi.fn(),
}));
+const memoryMocks = vi.hoisted(() => ({
+ appendMemory: vi.fn(),
+ getMemoryPathSummary: vi.fn(),
+ showMemory: vi.fn(),
+}));
+const agentMocks = vi.hoisted(() => ({
+ resetSystemMessage: vi.fn(),
+ withSystemMessage: vi.fn((messages: unknown[]) => messages),
+}));
const clearScreen = vi.hoisted(() => vi.fn());
vi.mock('@inkjs/ui', async () => {
@@ -100,6 +109,15 @@ vi.mock('@/components/CodeBlock', async () => ({
vi.mock('@/utils', async () => ({
...(await vi.importActual('@/utils')),
+ agents: {
+ resetSystemMessage: agentMocks.resetSystemMessage,
+ withSystemMessage: agentMocks.withSystemMessage,
+ },
+ memory: {
+ appendMemory: memoryMocks.appendMemory,
+ getMemoryPathSummary: memoryMocks.getMemoryPathSummary,
+ showMemory: memoryMocks.showMemory,
+ },
ollama: {
streamChat: vi.fn().mockImplementation(function* () {
yield { type: 'content', content: 'Mocked' };
@@ -237,6 +255,20 @@ function resetChatMocks() {
vi.mocked(ollama.hasUncalledToolIntent).mockReturnValue(false);
vi.mocked(tools.executeTool).mockReset();
vi.mocked(tools.runShell).mockReset();
+ memoryMocks.appendMemory.mockReset();
+ memoryMocks.getMemoryPathSummary.mockReset();
+ memoryMocks.showMemory.mockReset();
+ agentMocks.resetSystemMessage.mockReset();
+ agentMocks.withSystemMessage.mockImplementation(
+ (messages: unknown[]) => messages,
+ );
+ memoryMocks.appendMemory.mockReturnValue(
+ '/home/user/.code-ollama/memories/projects/repo-abc/MEMORY.md',
+ );
+ memoryMocks.getMemoryPathSummary.mockReturnValue(
+ 'Project memory: /home/user/.code-ollama/memories/projects/repo-abc/MEMORY.md',
+ );
+ memoryMocks.showMemory.mockReturnValue('Loaded memory:\n- Use Vitest.');
vi.mocked(tools.executeToolCall).mockImplementation((toolCall) =>
tools.executeTool(toolCall.function.name, toolCall.function.arguments),
);
@@ -447,6 +479,211 @@ describe('Chat', () => {
expect(onCommand).toHaveBeenCalledWith('/models');
});
+ it('shows memory without calling the LLM', async () => {
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory show');
+ rerender(chat);
+ await time.tick();
+
+ expect(memory.showMemory).toHaveBeenCalled();
+ expect(ollama.streamChat).not.toHaveBeenCalled();
+ expect(lastFrame()).toContain('Use Vitest.');
+ });
+
+ it('resolves a unique memory subcommand prefix', async () => {
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory s');
+ rerender(chat);
+ await time.tick();
+
+ expect(memory.showMemory).toHaveBeenCalled();
+ expect(lastFrame()).toContain('Use Vitest.');
+ expect(ollama.streamChat).not.toHaveBeenCalled();
+ });
+
+ it('shows memory paths for /memory path', async () => {
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory path');
+ rerender(chat);
+ await time.tick();
+
+ expect(memory.getMemoryPathSummary).toHaveBeenCalled();
+ expect(lastFrame()).toContain('Project memory:');
+ });
+
+ it('resolves a unique memory path prefix', async () => {
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory p');
+ rerender(chat);
+ await time.tick();
+
+ expect(memory.getMemoryPathSummary).toHaveBeenCalled();
+ expect(lastFrame()).toContain('Project memory:');
+ });
+
+ it('adds project memory and resets the cached system message', async () => {
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory add Use Vitest.');
+ rerender(chat);
+ await time.tick();
+
+ expect(memory.appendMemory).toHaveBeenCalledWith('Use Vitest.', {
+ scope: 'project',
+ });
+ expect(agents.resetSystemMessage).toHaveBeenCalled();
+ expect(lastFrame()).toContain('Memory saved to');
+ });
+
+ it('adds global memory with the --global flag', async () => {
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory add --global Use Vitest globally.');
+ rerender(chat);
+ await time.tick();
+
+ expect(memory.appendMemory).toHaveBeenCalledWith('Use Vitest globally.', {
+ scope: 'global',
+ });
+ expect(lastFrame()).toContain('Memory saved to');
+ expect(ollama.streamChat).not.toHaveBeenCalled();
+ });
+
+ it('shows usage for an unknown memory subcommand', async () => {
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory unknown-cmd');
+ rerender(chat);
+ await time.tick();
+
+ expect(lastFrame()).toContain('Unknown memory command.');
+ expect(ollama.streamChat).not.toHaveBeenCalled();
+ });
+
+ it('does not resolve an ambiguous memory add prefix', async () => {
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory a');
+ rerender(chat);
+ await time.tick();
+
+ expect(memory.appendMemory).not.toHaveBeenCalled();
+ expect(lastFrame()).toContain('Unknown memory command.');
+ });
+
+ it('shows an error message when the memory command throws an Error', async () => {
+ memoryMocks.appendMemory.mockImplementation(() => {
+ throw new Error('Disk full');
+ });
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory add some note');
+ rerender(chat);
+ await time.tick();
+
+ expect(lastFrame()).toContain('Memory command failed: Disk full');
+ expect(ollama.streamChat).not.toHaveBeenCalled();
+ });
+
+ it('shows a stringified error when the memory command throws a non-Error', async () => {
+ memoryMocks.appendMemory.mockImplementation(() => {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ throw 'quota exceeded';
+ });
+ const chat = (
+
+ );
+ const { lastFrame, rerender } = renderWithTheme(chat);
+ submitInput('/memory add some note');
+ rerender(chat);
+ await time.tick();
+
+ expect(lastFrame()).toContain('Memory command failed: quota exceeded');
+ expect(ollama.streamChat).not.toHaveBeenCalled();
+ });
+
it('runs a shell command locally without calling the LLM', async () => {
toolMocks.runShell.mockResolvedValue({ content: 'file1.txt\nfile2.txt' });
const onCommand = vi.fn();
diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx
index d282187c..f82c9538 100644
--- a/src/components/Chat/Chat.tsx
+++ b/src/components/Chat/Chat.tsx
@@ -14,9 +14,10 @@ import { PlanReview } from '@/components/PlanReview';
import { ToolApproval } from '@/components/ToolApproval';
import { DECISION, MODE, ROLE, THEME, UI } from '@/constants';
import type { Decision, Mode, ThemeDefinition } from '@/types';
-import { ollama, tools } from '@/utils';
+import { agents, memory, ollama, tools } from '@/utils';
import { ChatInput, type SubmittedInput } from './ChatInput';
+import { MEMORY_COMMANDS } from './CommandMenu';
import { ChatActionType, InterruptReason } from './constants';
import { useCompact, useRunTurn } from './hooks';
import { chatReducer, createInitialChatState } from './reducer';
@@ -33,6 +34,48 @@ interface Props {
theme?: ThemeDefinition;
}
+function getMemoryCommandResult(command: string): string {
+ const normalizedCommand = command.trim().toLowerCase();
+ const matchingSubmitCommands = MEMORY_COMMANDS.filter(
+ ({ value }) =>
+ value.shouldSubmit &&
+ value.text.toLowerCase().startsWith(normalizedCommand),
+ );
+ const resolvedCommand =
+ matchingSubmitCommands.length === 1
+ ? matchingSubmitCommands[0].value.text
+ : command;
+ const [, subcommand = 'show', ...args] = resolvedCommand.split(/\s+/);
+
+ switch (subcommand) {
+ case 'show':
+ return memory.showMemory();
+
+ case 'path':
+ return memory.getMemoryPathSummary();
+
+ case 'add': {
+ const isGlobal = args[0] === '--global';
+ const text = (isGlobal ? args.slice(1) : args).join(' ').trim();
+ const path = memory.appendMemory(text, {
+ scope: isGlobal ? 'global' : 'project',
+ });
+ agents.resetSystemMessage();
+ return `Memory saved to ${path}`;
+ }
+
+ default:
+ return [
+ 'Unknown memory command.',
+ 'Usage:',
+ '/memory show',
+ '/memory path',
+ '/memory add ',
+ '/memory add --global ',
+ ].join('\n');
+ }
+}
+
export function Chat({
initialMessages,
model,
@@ -250,6 +293,32 @@ export function Chat({
return;
}
+ if (
+ (userContent === '/memory' || userContent.startsWith('/memory ')) &&
+ !images?.length
+ ) {
+ try {
+ dispatch({
+ type: ChatActionType.AppendMessage,
+ message: {
+ role: ROLE.SYSTEM,
+ content: getMemoryCommandResult(userContent),
+ },
+ });
+ } catch (error) {
+ dispatch({
+ type: ChatActionType.AppendMessage,
+ message: {
+ role: ROLE.SYSTEM,
+ content: `Memory command failed: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ },
+ });
+ }
+ return;
+ }
+
if (userContent.startsWith('/')) {
onCommand(userContent);
return;
diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx
index 79082af7..8f603558 100644
--- a/src/components/Chat/ChatInput.test.tsx
+++ b/src/components/Chat/ChatInput.test.tsx
@@ -120,32 +120,139 @@ vi.mock('../TextInput', () => ({
}));
vi.mock('./CommandMenu', () => ({
+ getMatchingCommands: (input: string) => {
+ const normalizedInput = input.toLowerCase();
+ const trimmedInput = normalizedInput.trim();
+ const options =
+ normalizedInput === '/memory'
+ ? [
+ {
+ label: '/memory - show or update local memory',
+ value: { text: '/memory', shouldSubmit: true },
+ },
+ ]
+ : normalizedInput.startsWith('/memory ')
+ ? [
+ {
+ label: '/memory path - show memory file paths',
+ value: { text: '/memory path', shouldSubmit: true },
+ },
+ {
+ label: '/memory add - append project memory',
+ value: { text: '/memory add ', shouldSubmit: false },
+ },
+ ].filter(({ value }) =>
+ value.text.toLowerCase().startsWith(normalizedInput),
+ )
+ : COMMAND.LIST.filter(({ name }) =>
+ name.toLowerCase().startsWith(trimmedInput),
+ ).map(({ name, description }) => ({
+ label: `${name} - ${description}`,
+ value: { text: name, shouldSubmit: true },
+ }));
+
+ return options;
+ },
+ isSubmittableCommand: (value: string) => {
+ const trimmedValue = value.trim();
+
+ if (trimmedValue === '/memory') {
+ return true;
+ }
+
+ if (
+ trimmedValue === '/memory add' ||
+ trimmedValue === '/memory add --global'
+ ) {
+ return false;
+ }
+
+ if (trimmedValue.startsWith('/memory add --global ')) {
+ return (
+ trimmedValue.slice('/memory add --global '.length).trim().length > 0
+ );
+ }
+
+ if (trimmedValue.startsWith('/memory add ')) {
+ return trimmedValue.slice('/memory add '.length).trim().length > 0;
+ }
+
+ if (
+ ['/memory show', '/memory path'].some((command) =>
+ command.startsWith(trimmedValue),
+ )
+ ) {
+ return true;
+ }
+
+ return COMMAND.LIST.some(({ name }) => name === trimmedValue);
+ },
CommandMenu: ({
input,
+ onComplete,
onSubmit,
}: {
input: string;
+ onComplete?: (value: string) => void;
onSubmit: (value: string) => void;
}) => {
- const normalizedInput = input.trim().toLowerCase();
+ const normalizedInput = input.toLowerCase();
+ const trimmedInput = normalizedInput.trim();
const options =
- normalizedInput === '/unknown'
+ trimmedInput === '/unknown'
? [
{
label: '/unknown - invalid command',
value: '/unknown',
+ shouldComplete: false,
},
]
- : COMMAND.LIST.filter(({ name }) =>
- name.toLowerCase().startsWith(normalizedInput),
- ).map(({ name, description }) => ({
- label: `${name} - ${description}`,
- value: name,
- }));
+ : trimmedInput === '/mo'
+ ? [
+ {
+ label: '/models - manage Ollama models',
+ value: '/models',
+ shouldComplete: true,
+ },
+ ]
+ : normalizedInput === '/memory'
+ ? [
+ {
+ label: '/memory - show or update local memory',
+ value: '/memory',
+ shouldComplete: false,
+ },
+ ]
+ : normalizedInput.startsWith('/memory ')
+ ? [
+ {
+ label: '/memory path - show memory file paths',
+ value: '/memory path',
+ shouldComplete: false,
+ },
+ {
+ label: '/memory add - append project memory',
+ value: '/memory add ',
+ shouldComplete: true,
+ },
+ ].filter(({ value }) =>
+ value.toLowerCase().startsWith(normalizedInput),
+ )
+ : COMMAND.LIST.filter(({ name }) =>
+ name.toLowerCase().startsWith(trimmedInput),
+ ).map(({ name, description }) => ({
+ label: `${name} - ${description}`,
+ value: name,
+ shouldComplete: false,
+ }));
useInput((_, key) => {
if (key.return && options[0]) {
- onSubmit(options[0].value);
+ if (options[0].shouldComplete && onComplete) {
+ onComplete(options[0].value);
+ } else {
+ onSubmit(options[0].value);
+ }
}
});
@@ -414,6 +521,81 @@ describe('ChatInput', () => {
expect(onSubmit).toHaveBeenCalledWith({ content: '/clear' });
});
+ it('submits selected memory command suggestions instead of the raw input', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = renderInput({ onSubmit });
+ stdin.write('/memory ');
+ await time.tick();
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ expect(onSubmit).toHaveBeenCalledWith({ content: '/memory path' });
+ expect(onSubmit).not.toHaveBeenCalledWith({ content: '/memory ' });
+ });
+
+ it('submits memory subcommands on Enter when typed directly', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = renderInput({ onSubmit });
+ stdin.write('/memory show');
+ await time.tick();
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ expect(onSubmit).toHaveBeenCalledWith({ content: '/memory show' });
+ });
+
+ it('submits /memory on Enter when typed directly', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = renderInput({ onSubmit });
+ stdin.write('/memory');
+ await time.tick();
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ expect(onSubmit).toHaveBeenCalledWith({ content: '/memory' });
+ });
+
+ it('submits unique memory command prefixes on Enter when typed directly', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = renderInput({ onSubmit });
+ stdin.write('/memory s');
+ await time.tick();
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ expect(onSubmit).toHaveBeenCalledWith({ content: '/memory s' });
+ });
+
+ it('does not submit incomplete memory add prefixes on Enter', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = renderInput({ onSubmit });
+ stdin.write('/memory a');
+ await time.tick();
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('submits memory add with text on Enter when typed directly', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = renderInput({ onSubmit });
+ stdin.write('/memory add Use Vitest.');
+ await time.tick();
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ expect(onSubmit).toHaveBeenCalledWith({
+ content: '/memory add Use Vitest.',
+ });
+ });
+
+ it('submits memory add --global with text on Enter when typed directly', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = renderInput({ onSubmit });
+ stdin.write('/memory add --global Use Vitest globally.');
+ await time.tick();
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ expect(onSubmit).toHaveBeenCalledWith({
+ content: '/memory add --global Use Vitest globally.',
+ });
+ });
+
it('ignores slash command submissions that are not in the command list', async () => {
const onSubmit = vi.fn();
const { stdin } = renderInput({ onSubmit });
@@ -1157,6 +1339,17 @@ describe('ChatInput', () => {
expect(lastFrame()).toContain('bck-i-search: _');
});
+ it('completes a command without submitting when CommandMenu calls onComplete', async () => {
+ const onSubmit = vi.fn();
+ const { lastFrame, stdin } = renderInput({ onSubmit });
+ stdin.write('/mo');
+ await time.tick();
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(lastFrame()).toContain('/models');
+ });
+
it('does not add slash commands to prompt history after a session change', async () => {
const onSubmit = vi.fn();
const { lastFrame, rerender, stdin } = renderInput({
diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx
index 32869058..9a861843 100644
--- a/src/components/Chat/ChatInput.tsx
+++ b/src/components/Chat/ChatInput.tsx
@@ -2,7 +2,7 @@ import { Box, Text, useApp, useInput } from 'ink';
import { useCallback, useEffect, useRef, useState } from 'react';
import { TextInput } from '@/components/TextInput';
-import { COMMAND, KEY, UI } from '@/constants';
+import { KEY, UI } from '@/constants';
import { useTheme } from '@/contexts';
import { clipboard } from '@/utils';
@@ -11,7 +11,11 @@ import {
extractImageAttachments,
getAttachmentLabel,
} from './attachments';
-import { CommandMenu } from './CommandMenu';
+import {
+ CommandMenu,
+ getMatchingCommands,
+ isSubmittableCommand,
+} from './CommandMenu';
import { FileSuggestions } from './FileSuggestions';
import { useHistorySearch } from './hooks';
@@ -307,6 +311,14 @@ export function ChatInput({
const handleSubmitText = useCallback(
(value: string) => {
if (value.startsWith('/') && !value.includes('\n')) {
+ if (getMatchingCommands(value).length) {
+ return;
+ }
+
+ if (isSubmittableCommand(value)) {
+ submitAndReset(value);
+ }
+
return;
}
@@ -325,13 +337,18 @@ export function ChatInput({
const handleSubmitCommand = useCallback(
(value: string) => {
- if (COMMAND.LIST.find(({ name }) => name === value)) {
+ if (isSubmittableCommand(value)) {
submitAndReset(value);
}
},
[submitAndReset],
);
+ const handleCompleteCommand = useCallback((value: string) => {
+ setInput(value);
+ setCursorPosition(value.length);
+ }, []);
+
useInput((inputKey, key) => {
const isEscape =
key.escape || inputKey === KEY.ESCAPE || inputKey === '\x1B';
@@ -468,7 +485,11 @@ export function ChatInput({
)}
{!historySearch.isActive && showCommandMenu && (
-
+
)}
{!historySearch.isActive && showFileSuggestions && (
diff --git a/src/components/Chat/CommandMenu.test.tsx b/src/components/Chat/CommandMenu.test.tsx
index 3b18a2f1..c896e33b 100644
--- a/src/components/Chat/CommandMenu.test.tsx
+++ b/src/components/Chat/CommandMenu.test.tsx
@@ -2,9 +2,24 @@ import { KEY } from '@/constants';
import { time } from '@/utils';
import { renderWithTheme } from '@/utils/testing';
-import { CommandMenu } from './CommandMenu';
+import { CommandMenu, isSubmittableCommand } from './CommandMenu';
describe('CommandMenu', () => {
+ it.each([
+ ['/models', true],
+ ['/memory', true],
+ ['/memory s', true],
+ ['/memory path', true],
+ ['/memory add', false],
+ ['/memory add ', false],
+ ['/memory add Use Vitest.', true],
+ ['/memory add --global', false],
+ ['/memory add --global Use Vitest.', true],
+ ['/unknown', false],
+ ])('reports whether %s is submittable', (command, expected) => {
+ expect(isSubmittableCommand(command)).toBe(expected);
+ });
+
it('returns null when input does not start with a slash', () => {
const onSubmit = vi.fn();
const { lastFrame } = renderWithTheme(
@@ -39,6 +54,20 @@ describe('CommandMenu', () => {
expect(onSubmit).toHaveBeenCalledWith('/models');
});
+ it('completes a top-level command with Tab instead of submitting', async () => {
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.TAB);
+ await time.tick();
+
+ expect(onComplete).toHaveBeenCalledWith('/models');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
it('includes /mcp in matching command results', () => {
const onSubmit = vi.fn();
const { lastFrame } = renderWithTheme(
@@ -48,6 +77,120 @@ describe('CommandMenu', () => {
expect(lastFrame()).toContain('/mcp - show MCP server status');
});
+ it('completes /mem to /memory space instead of submitting', async () => {
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.TAB);
+ await time.tick();
+
+ expect(onComplete).toHaveBeenCalledWith('/memory ');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('completes exact /memory with Tab instead of submitting', async () => {
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.TAB);
+ await time.tick();
+
+ expect(onComplete).toHaveBeenCalledWith('/memory');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('submits exact /memory when selected', async () => {
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.ENTER);
+ await time.tick();
+
+ expect(onSubmit).toHaveBeenCalledWith('/memory');
+ expect(onComplete).not.toHaveBeenCalled();
+ });
+
+ it('renders memory subcommands after /memory space', () => {
+ const onSubmit = vi.fn();
+ const { lastFrame } = renderWithTheme(
+ ,
+ );
+
+ expect(lastFrame()).toContain('/memory show - display loaded memory');
+ expect(lastFrame()).toContain('/memory add ');
+ });
+
+ it('completes memory add instead of submitting it', async () => {
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.TAB);
+ await time.tick();
+
+ expect(onComplete).toHaveBeenCalledWith('/memory add ');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('submits memory show when selected', async () => {
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.ENTER);
+ await time.tick();
+
+ expect(onSubmit).toHaveBeenCalledWith('/memory show');
+ expect(onComplete).not.toHaveBeenCalled();
+ });
+
+ it('calls onComplete via onSelect when selecting a non-submittable memory add option with Enter', async () => {
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.ENTER);
+ await time.tick();
+
+ expect(onComplete).toHaveBeenCalledWith('/memory add ');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
it('moves focus through slash commands before selecting', async () => {
const onSubmit = vi.fn();
const { stdin } = renderWithTheme(
diff --git a/src/components/Chat/CommandMenu.tsx b/src/components/Chat/CommandMenu.tsx
index 35aa1f23..5029f4b2 100644
--- a/src/components/Chat/CommandMenu.tsx
+++ b/src/components/Chat/CommandMenu.tsx
@@ -5,24 +5,106 @@ import { COMMAND } from '@/constants';
interface Props {
input: string;
+ onComplete?: (value: string) => void;
onSubmit: (value: string) => void;
}
-function getMatchingCommands(input: string) {
- const normalizedInput = input.trim().toLowerCase();
+export interface CommandOptionValue {
+ shouldSubmit: boolean;
+ text: string;
+}
+
+interface CommandOption {
+ label: string;
+ value: CommandOptionValue;
+}
+
+export const MEMORY_COMMANDS: CommandOption[] = [
+ {
+ label: '/memory show - display loaded memory',
+ value: { shouldSubmit: true, text: '/memory show' },
+ },
+ {
+ label: '/memory path - show memory file paths',
+ value: { shouldSubmit: true, text: '/memory path' },
+ },
+ {
+ label: '/memory add - append project memory',
+ value: { shouldSubmit: false, text: '/memory add ' },
+ },
+ {
+ label: '/memory add --global - append global memory',
+ value: { shouldSubmit: false, text: '/memory add --global ' },
+ },
+];
+
+export function getMatchingCommands(input: string) {
+ const normalizedInput = input.toLowerCase();
if (!normalizedInput.startsWith('/')) {
return [];
}
+ if (normalizedInput.startsWith('/memory ')) {
+ return MEMORY_COMMANDS.filter(({ value }) =>
+ value.text.toLowerCase().startsWith(normalizedInput),
+ );
+ }
+
return COMMAND.LIST.filter(({ name }) =>
- name.toLowerCase().startsWith(normalizedInput),
- ).map(({ name, description }) => ({
- label: `${name} - ${description}`,
- value: name,
- }));
+ name.toLowerCase().startsWith(normalizedInput.trim()),
+ ).map(({ name, description }) => {
+ const shouldCompleteMemory =
+ name === '/memory' && normalizedInput.trim() !== '/memory';
+
+ return {
+ label: `${name} - ${description}`,
+ value: shouldCompleteMemory
+ ? { shouldSubmit: false, text: '/memory ' }
+ : { shouldSubmit: true, text: name },
+ };
+ });
}
-export function CommandMenu({ input, onSubmit }: Props) {
+export function isSubmittableCommand(value: string): boolean {
+ // v8 ignore next
+ if (value.includes('\n')) {
+ return false;
+ }
+
+ const trimmedValue = value.trim();
+ const runnableMemoryCommands = MEMORY_COMMANDS.filter(
+ ({ value }) => value.shouldSubmit,
+ ).map(({ value }) => value.text);
+
+ if (trimmedValue === '/memory') {
+ return true;
+ }
+
+ if (
+ trimmedValue === '/memory add' ||
+ trimmedValue === '/memory add --global'
+ ) {
+ return false;
+ }
+
+ if (trimmedValue.startsWith('/memory add --global ')) {
+ return trimmedValue.slice('/memory add --global '.length).trim().length > 0;
+ }
+
+ if (trimmedValue.startsWith('/memory add ')) {
+ return trimmedValue.slice('/memory add '.length).trim().length > 0;
+ }
+
+ if (
+ runnableMemoryCommands.some((command) => command.startsWith(trimmedValue))
+ ) {
+ return true;
+ }
+
+ return COMMAND.LIST.some(({ name }) => name === trimmedValue);
+}
+
+export function CommandMenu({ input, onComplete, onSubmit }: Props) {
const commandOptions = useMemo(() => getMatchingCommands(input), [input]);
if (!commandOptions.length) {
@@ -31,8 +113,16 @@ export function CommandMenu({ input, onSubmit }: Props) {
return (
{
+ onComplete?.(option.value.text);
+ }}
onSelect={(option) => {
- onSubmit(option.value);
+ if (option.value.shouldSubmit) {
+ onSubmit(option.value.text);
+ return;
+ }
+
+ onComplete?.(option.value.text);
}}
options={commandOptions}
/>
diff --git a/src/components/Messages/Message.tsx b/src/components/Messages/Message.tsx
index 51168fa7..a8182871 100644
--- a/src/components/Messages/Message.tsx
+++ b/src/components/Messages/Message.tsx
@@ -18,6 +18,7 @@ import { getMessageColor } from './styles';
interface Props {
message: OllamaMessage;
isStreaming?: boolean;
+ marginBottom?: number;
}
function renderStickyPaddingLines(count: number): React.ReactElement[] {
@@ -29,16 +30,22 @@ function renderStickyPaddingLines(count: number): React.ReactElement[] {
}
function ToolResultMessage({
+ marginBottom,
message,
messageColor,
}: {
+ marginBottom: number;
message: OllamaMessage;
messageColor?: string;
}) {
const diffContent = message.toolResult?.diff?.visible;
return (
-
+
{message.content}
@@ -50,7 +57,11 @@ function ToolResultMessage({
);
}
-export function Message({ message, isStreaming = false }: Props) {
+export function Message({
+ message,
+ isStreaming = false,
+ marginBottom = 1,
+}: Props) {
const theme = useTheme();
const messageColor = getMessageColor(message.role, theme);
const isSystem = message.role === ROLE.SYSTEM;
@@ -70,12 +81,20 @@ export function Message({ message, isStreaming = false }: Props) {
if (isSystem) {
if (message.toolResult?.diff) {
return (
-
+
);
}
return (
-
+
{message.content}
@@ -92,7 +111,7 @@ export function Message({ message, isStreaming = false }: Props) {
// v8 ignore stop
return (
-
+
{UI.PROMPT_PREFIX}
{attachmentPrefix ? (
@@ -152,7 +171,7 @@ export function Message({ message, isStreaming = false }: Props) {
: 0;
return (
-
+
{segments.map((segment, index) => {
if (segment.type === 'code') {
return (
diff --git a/src/components/Messages/Messages.tsx b/src/components/Messages/Messages.tsx
index 9bcbcf99..c5361ed6 100644
--- a/src/components/Messages/Messages.tsx
+++ b/src/components/Messages/Messages.tsx
@@ -27,7 +27,20 @@ export function Messages({
return (
<>
- {(message, index) => }
+ {(message, index) => {
+ const isLastTranscriptMessage =
+ index === transcriptMessages.length - 1 &&
+ !streamingMessage &&
+ !isLoading;
+
+ return (
+
+ );
+ }}
{streamingMessage && }
diff --git a/src/components/Suggestions/Suggestions.test.tsx b/src/components/Suggestions/Suggestions.test.tsx
index ef6af6f2..d288ca94 100644
--- a/src/components/Suggestions/Suggestions.test.tsx
+++ b/src/components/Suggestions/Suggestions.test.tsx
@@ -69,6 +69,30 @@ describe('Suggestions', () => {
},
);
+ it('calls onComplete instead of onSelect when Tab is pressed and onComplete is provided', async () => {
+ const onComplete = vi.fn();
+ const onSelect = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.TAB);
+ await time.tick();
+
+ expect(onComplete).toHaveBeenCalledWith({
+ label: 'alpha',
+ value: 'alpha',
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
it('ignores unrelated printable input', async () => {
const onSelect = vi.fn();
const { stdin } = renderWithTheme(
diff --git a/src/components/Suggestions/Suggestions.tsx b/src/components/Suggestions/Suggestions.tsx
index 5343d265..d167cfc1 100644
--- a/src/components/Suggestions/Suggestions.tsx
+++ b/src/components/Suggestions/Suggestions.tsx
@@ -15,6 +15,7 @@ interface Props {
isDisabled?: boolean;
maxVisibleOptions?: number;
resetKey?: string;
+ onComplete?: (option: SuggestionOption) => void;
onHighlight?: (option: SuggestionOption | null) => void;
onSelect: (option: SuggestionOption) => void;
}
@@ -24,6 +25,7 @@ export function Suggestions({
isDisabled = false,
maxVisibleOptions = DEFAULT_MAX_VISIBLE_OPTIONS,
resetKey,
+ onComplete,
onHighlight,
onSelect,
}: Props) {
@@ -66,7 +68,18 @@ export function Suggestions({
return;
}
- if (key.tab || key.return) {
+ if (key.tab) {
+ const option = options[focusedIndex];
+ if (onComplete) {
+ onComplete(option);
+ return;
+ }
+
+ onSelect(option);
+ return;
+ }
+
+ if (key.return) {
onSelect(options[focusedIndex]);
}
});
diff --git a/src/components/TextInput/TextInput.test.tsx b/src/components/TextInput/TextInput.test.tsx
index 20678155..01b88531 100644
--- a/src/components/TextInput/TextInput.test.tsx
+++ b/src/components/TextInput/TextInput.test.tsx
@@ -136,6 +136,20 @@ describe('TextInput', () => {
expect(onSubmit).toHaveBeenCalledWith('test');
});
+ it('keeps cursor position after Enter when the value is unchanged', async () => {
+ const onChange = vi.fn();
+ const { stdin } = renderWithTheme(
+ ,
+ );
+
+ stdin.write(KEY.ENTER);
+ await time.tick();
+ stdin.write('!');
+ await time.tick();
+
+ expect(onChange).toHaveBeenCalledWith('test!');
+ });
+
it('submits on Enter when multiline paste is enabled', async () => {
const onChange = vi.fn();
const onSubmit = vi.fn();
diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx
index 55b5b1ca..792e3705 100644
--- a/src/components/TextInput/TextInput.tsx
+++ b/src/components/TextInput/TextInput.tsx
@@ -160,7 +160,6 @@ export function TextInput({
if (key.return) {
onSubmit(value);
- setCursorPosition(0);
return;
}
diff --git a/src/constants/command.ts b/src/constants/command.ts
index 9c14fa1d..9a6f7e59 100644
--- a/src/constants/command.ts
+++ b/src/constants/command.ts
@@ -6,6 +6,7 @@ export const LIST: CommandList[] = [
{ name: '/sessions', description: 'manage sessions' },
{ name: '/models', description: 'manage Ollama models' },
{ name: '/mcp', description: 'show MCP server status' },
+ { name: '/memory', description: 'show or update local memory' },
{ name: '/skills', description: 'show loaded skills' },
{ name: '/theme', description: 'change the theme' },
{ name: '/search', description: 'configure web search' },
diff --git a/src/utils/agents.test.ts b/src/utils/agents.test.ts
index 283acf96..d1047755 100644
--- a/src/utils/agents.test.ts
+++ b/src/utils/agents.test.ts
@@ -11,6 +11,7 @@ const loadSkills = vi.hoisted(() => vi.fn<() => Skill[]>(() => []));
const loadConfig = vi.hoisted(() =>
vi.fn(() => ({ disabledSkills: [] as string[] })),
);
+const loadMemoryForPrompt = vi.hoisted(() => vi.fn<() => string | null>());
vi.mock('./skills', async () => {
const actual = await vi.importActual('./skills');
@@ -25,9 +26,14 @@ vi.mock('./config', () => ({
loadConfig,
}));
+vi.mock('./memory', () => ({
+ loadMemoryForPrompt,
+}));
+
describe('agents', () => {
beforeEach(() => {
loadSkills.mockReturnValue([]);
+ loadMemoryForPrompt.mockReturnValue(null);
});
it('creates system message with base prompt', async () => {
@@ -83,6 +89,17 @@ describe('agents', () => {
expect(prompt).toContain('Review pull requests');
});
+ it('includes loaded memory when available', async () => {
+ vi.mocked(existsSync).mockReturnValue(false);
+ loadMemoryForPrompt.mockReturnValue('Loaded memory:\nProject fact');
+
+ const { buildSystemPrompt } = await import('./agents');
+ const prompt = buildSystemPrompt();
+
+ expect(prompt).toContain('Loaded memory:');
+ expect(prompt).toContain('Project fact');
+ });
+
it('omits skills section when no skills are loaded', async () => {
vi.mocked(existsSync).mockReturnValue(false);
loadConfig.mockReturnValue({ disabledSkills: [] });
diff --git a/src/utils/agents.ts b/src/utils/agents.ts
index eae876d4..67942341 100644
--- a/src/utils/agents.ts
+++ b/src/utils/agents.ts
@@ -4,6 +4,7 @@ import { join } from 'node:path';
import { PROMPT, ROLE } from '@/constants';
import { loadConfig } from './config';
+import { loadMemoryForPrompt } from './memory';
import type { Message } from './ollama';
import { formatSkillsForPrompt, loadSkills } from './skills';
@@ -32,6 +33,11 @@ export function buildSystemPrompt(): string {
parts.push('\n\nProject context from AGENTS.md:\n', agentsContent);
}
+ const memoryContent = loadMemoryForPrompt();
+ if (memoryContent) {
+ parts.push('\n\n', memoryContent);
+ }
+
const config = loadConfig();
const allSkills = loadSkills({ disabledSkills: config.disabledSkills });
const enabledSkills = allSkills.filter((skill) => !skill.isDisabled);
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 0f362bd0..442a3374 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -2,6 +2,7 @@ export * as agents from './agents';
export * as clipboard from './clipboard';
export * as config from './config';
export * as mcp from './mcp';
+export * as memory from './memory';
export * as node from './node';
export * as ollama from './ollama';
export * as screen from './screen';
diff --git a/src/utils/memory.test.ts b/src/utils/memory.test.ts
new file mode 100644
index 00000000..f4f664b2
--- /dev/null
+++ b/src/utils/memory.test.ts
@@ -0,0 +1,388 @@
+import { execFileSync } from 'node:child_process';
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+
+const mockConfig = vi.hoisted(() => ({
+ directory: `/tmp/code-ollama-memory-test-${String(Date.now())}`,
+}));
+
+vi.mock('node:child_process', () => ({
+ execFileSync: vi.fn(),
+}));
+
+vi.mock('@/constants', () => ({
+ CONFIG: {
+ DIRECTORY: mockConfig.directory,
+ },
+}));
+
+describe('memory', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ rmSync(mockConfig.directory, { recursive: true, force: true });
+ });
+
+ afterAll(() => {
+ rmSync(mockConfig.directory, { recursive: true, force: true });
+ });
+
+ it('uses the normalized git remote for stable project ids', async () => {
+ vi.mocked(execFileSync).mockImplementation((command, args, options) => {
+ const gitArgs = args as string[];
+ const cwd = (options as { cwd: string }).cwd;
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return cwd.includes('moved')
+ ? '/Users/mark/moved/code-ollama\n'
+ : '/Users/mark/Code/npm/code-ollama\n';
+ }
+
+ if (gitArgs.join(' ') === 'config --get remote.origin.url') {
+ return 'git@github.com:owner/code-ollama.git\n';
+ }
+
+ throw new Error(`Unexpected command: ${command}`);
+ });
+
+ const { getMemoryPaths } = await import('./memory');
+
+ const first = getMemoryPaths('/Users/mark/Code/npm/code-ollama');
+ const moved = getMemoryPaths('/Users/mark/moved/code-ollama');
+
+ expect(first.project.id).toBe(moved.project.id);
+ expect(first.project.id).toMatch(/^code-ollama-[a-f0-9]{12}$/);
+ expect(first.project.key).toBe('remote:github.com/owner/code-ollama');
+ });
+
+ it('falls back to the git root path when no remote exists', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args, options) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return `${(options as { cwd: string }).cwd}\n`;
+ }
+
+ throw new Error('no remote');
+ });
+
+ const { getMemoryPaths } = await import('./memory');
+
+ const first = getMemoryPaths('/tmp/one/api');
+ const second = getMemoryPaths('/tmp/two/api');
+
+ expect(first.project.id).toMatch(/^api-[a-f0-9]{12}$/);
+ expect(second.project.id).toMatch(/^api-[a-f0-9]{12}$/);
+ expect(first.project.id).not.toBe(second.project.id);
+ expect(first.project.key).toBe('path:/tmp/one/api');
+ });
+
+ it('loads only bounded global and project memory', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ if (gitArgs.join(' ') === 'config --get remote.origin.url') {
+ return 'https://github.com/owner/repo.git\n';
+ }
+
+ throw new Error('unexpected git command');
+ });
+
+ const { getMemoryPaths, loadMemoryForPrompt, MEMORY_LIMITS } =
+ await import('./memory');
+ const paths = getMemoryPaths('/repo');
+
+ mkdirSync(paths.globalDirectory, { recursive: true });
+ mkdirSync(paths.project.directory, { recursive: true });
+ writeFileSync(paths.globalMemoryPath, 'global fact\n', 'utf8');
+ const projectContent = Array.from(
+ { length: MEMORY_LIMITS.maxLines + 1 },
+ (_, index) => `project line ${String(index + 1)}`,
+ ).join('\n');
+ writeFileSync(paths.projectMemoryPath, projectContent, 'utf8');
+
+ const prompt = loadMemoryForPrompt('/repo') ?? '';
+
+ expect(prompt).toContain('Loaded memory:');
+ expect(prompt).toContain('global fact');
+ expect(prompt).toContain('project line 200');
+ expect(prompt).not.toContain('project line 201');
+ });
+
+ it('falls back to project slug when remote basename is empty', async () => {
+ const { MEMORY_TEST_ONLY } = await import('./memory');
+ vi.mocked(execFileSync).mockImplementation(() => {
+ throw new Error('git not found');
+ });
+
+ expect(MEMORY_TEST_ONLY.slugify('---')).toBe('project');
+ });
+
+ it('falls back to project slug when remote normalizes to an empty basename', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ if (gitArgs.join(' ') === 'config --get remote.origin.url') {
+ return 'ssh:///\n';
+ }
+
+ throw new Error('unexpected git command');
+ });
+
+ const { getMemoryPaths } = await import('./memory');
+ const paths = getMemoryPaths('/repo');
+
+ expect(paths.project.slug).toBe('project');
+ });
+
+ it('falls back to cwd when git is not available', async () => {
+ vi.mocked(execFileSync).mockImplementation(() => {
+ throw new Error('git not found');
+ });
+
+ const { getMemoryPaths } = await import('./memory');
+ const paths = getMemoryPaths('/no-git-here');
+
+ expect(paths.project.gitRoot).toBe('/no-git-here');
+ expect(paths.project.key).toBe('path:/no-git-here');
+ });
+
+ it('returns a path summary string', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ throw new Error('no remote');
+ });
+
+ const { getMemoryPathSummary } = await import('./memory');
+ const summary = getMemoryPathSummary('/repo');
+
+ expect(summary).toContain('Global memory:');
+ expect(summary).toContain('Project memory:');
+ expect(summary).not.toContain('Project metadata:');
+ });
+
+ it('returns null when neither global nor project memory file exists', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ throw new Error('no remote');
+ });
+
+ const { loadMemoryForPrompt } = await import('./memory');
+ expect(loadMemoryForPrompt('/repo')).toBeNull();
+ });
+
+ it('showMemory returns fallback message when no memory files exist', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ throw new Error('no remote');
+ });
+
+ const { showMemory } = await import('./memory');
+ const result = showMemory('/repo');
+
+ expect(result).toBe('No memory found.');
+ });
+
+ it('throws when appendMemory is called with empty text', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ throw new Error('no remote');
+ });
+
+ const { appendMemory } = await import('./memory');
+
+ expect(() => appendMemory(' ')).toThrow('Memory text is required.');
+ });
+
+ it('renders only global memory when project memory is absent', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ throw new Error('no remote');
+ });
+
+ const { getMemoryPaths, loadMemoryForPrompt } = await import('./memory');
+ const paths = getMemoryPaths('/repo');
+
+ mkdirSync(paths.globalDirectory, { recursive: true });
+ writeFileSync(paths.globalMemoryPath, 'global only\n', 'utf8');
+
+ const result = loadMemoryForPrompt('/repo') ?? '';
+
+ expect(result).toContain('global only');
+ expect(result).not.toContain('Project memory');
+ });
+
+ it('renders only project memory when global memory is absent', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ if (gitArgs.join(' ') === 'config --get remote.origin.url') {
+ return 'https://github.com/owner/repo.git\n';
+ }
+
+ throw new Error('unexpected git command');
+ });
+
+ const { getMemoryPaths, loadMemoryForPrompt } = await import('./memory');
+ const paths = getMemoryPaths('/repo');
+
+ mkdirSync(paths.project.directory, { recursive: true });
+ writeFileSync(paths.projectMemoryPath, 'project only\n', 'utf8');
+
+ const result = loadMemoryForPrompt('/repo') ?? '';
+
+ expect(result).toContain('project only');
+ expect(result).not.toContain('Global memory');
+ });
+
+ it('writes metadata without gitRemote when no remote is configured', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ throw new Error('no remote');
+ });
+
+ const { appendMemory, getMemoryPaths } = await import('./memory');
+ const paths = getMemoryPaths('/repo');
+
+ appendMemory('Local note.', { cwd: '/repo' });
+
+ const metadata = JSON.parse(
+ readFileSync(paths.projectMetadataPath, 'utf8'),
+ ) as Record;
+
+ expect(metadata).not.toHaveProperty('gitRemote');
+ expect(metadata).toHaveProperty('gitRoot', '/repo');
+ });
+
+ it('returns null from readBoundedMarkdown when file is unreadable', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ throw new Error('no remote');
+ });
+
+ const { getMemoryPaths, loadMemoryForPrompt } = await import('./memory');
+ const paths = getMemoryPaths('/repo');
+
+ mkdirSync(paths.globalDirectory, { recursive: true });
+ writeFileSync(paths.globalMemoryPath, 'global fact\n', 'utf8');
+ chmodSync(paths.globalMemoryPath, 0);
+
+ expect(loadMemoryForPrompt('/repo')).toBeNull();
+
+ chmodSync(paths.globalMemoryPath, 0o644);
+ });
+
+ it('ensureMemoryFile and writeProjectMetadata are idempotent on second append', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ if (gitArgs.join(' ') === 'config --get remote.origin.url') {
+ return 'https://github.com/owner/repo.git\n';
+ }
+
+ throw new Error('unexpected git command');
+ });
+
+ const { appendMemory, getMemoryPaths } = await import('./memory');
+ const paths = getMemoryPaths('/repo');
+
+ appendMemory('First note.', { cwd: '/repo' });
+ appendMemory('Second note.', { cwd: '/repo' });
+ appendMemory('First global.', { cwd: '/repo', scope: 'global' });
+ appendMemory('Second global.', { cwd: '/repo', scope: 'global' });
+
+ expect(readFileSync(paths.projectMemoryPath, 'utf8')).toContain(
+ '- Second note.',
+ );
+ expect(readFileSync(paths.globalMemoryPath, 'utf8')).toContain(
+ '- Second global.',
+ );
+ expect(existsSync(paths.projectMetadataPath)).toBe(true);
+ });
+
+ it('appends project and global memory lazily', async () => {
+ vi.mocked(execFileSync).mockImplementation((_, args) => {
+ const gitArgs = args as string[];
+
+ if (gitArgs.join(' ') === 'rev-parse --show-toplevel') {
+ return '/repo\n';
+ }
+
+ if (gitArgs.join(' ') === 'config --get remote.origin.url') {
+ return 'https://github.com/owner/repo.git\n';
+ }
+
+ throw new Error('unexpected git command');
+ });
+
+ const { appendMemory, getMemoryPaths } = await import('./memory');
+ const paths = getMemoryPaths('/repo');
+
+ appendMemory('Use Vitest.', { cwd: '/repo' });
+ appendMemory('Prefer small changes.', { cwd: '/repo', scope: 'global' });
+
+ expect(readFileSync(paths.projectMemoryPath, 'utf8')).toContain(
+ '- Use Vitest.',
+ );
+ expect(readFileSync(paths.globalMemoryPath, 'utf8')).toContain(
+ '- Prefer small changes.',
+ );
+ expect(existsSync(paths.projectMetadataPath)).toBe(true);
+ });
+});
diff --git a/src/utils/memory.ts b/src/utils/memory.ts
new file mode 100644
index 00000000..cd518a3b
--- /dev/null
+++ b/src/utils/memory.ts
@@ -0,0 +1,238 @@
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import {
+ appendFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ writeFileSync,
+} from 'node:fs';
+import { basename, dirname, join } from 'node:path';
+
+import { CONFIG } from '@/constants';
+
+const MEMORIES_DIRECTORY = join(CONFIG.DIRECTORY, 'memories');
+const GLOBAL_MEMORY_DIRECTORY = join(MEMORIES_DIRECTORY, 'global');
+const PROJECTS_MEMORY_DIRECTORY = join(MEMORIES_DIRECTORY, 'projects');
+const MEMORY_FILE = 'MEMORY.md';
+const METADATA_FILE = 'metadata.json';
+const MAX_MEMORY_LINES = 200;
+const MAX_MEMORY_BYTES = 25 * 1024;
+const HASH_LENGTH = 12;
+
+export interface ProjectMemoryIdentity {
+ id: string;
+ slug: string;
+ hash: string;
+ key: string;
+ gitRoot: string;
+ gitRemote?: string;
+ directory: string;
+}
+
+export interface MemoryPaths {
+ globalDirectory: string;
+ globalMemoryPath: string;
+ project: ProjectMemoryIdentity;
+ projectMemoryPath: string;
+ projectMetadataPath: string;
+}
+
+function runGit(args: string[], cwd: string): string | null {
+ try {
+ return execFileSync('git', args, {
+ cwd,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim();
+ } catch {
+ return null;
+ }
+}
+
+function normalizeRemote(remote: string): string {
+ return remote
+ .trim()
+ .replace(/^ssh:\/\//, '')
+ .replace(/^git@([^:]+):/, '$1/')
+ .replace(/^https?:\/\//, '')
+ .replace(/\.git$/, '')
+ .replace(/\/+$/, '')
+ .toLowerCase();
+}
+
+function getRemoteSlug(remote: string): string {
+ const normalized = normalizeRemote(remote);
+ return basename(normalized) || 'project';
+}
+
+function slugify(value: string): string {
+ const slug = value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+
+ return slug || 'project';
+}
+
+function hashKey(key: string): string {
+ return createHash('sha256').update(key).digest('hex').slice(0, HASH_LENGTH);
+}
+
+function resolveProjectIdentity(cwd = process.cwd()): ProjectMemoryIdentity {
+ const gitRoot = runGit(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
+ const gitRemote = runGit(['config', '--get', 'remote.origin.url'], gitRoot);
+ const key = gitRemote
+ ? `remote:${normalizeRemote(gitRemote)}`
+ : `path:${gitRoot}`;
+ const slugSource = gitRemote ? getRemoteSlug(gitRemote) : basename(gitRoot);
+ const slug = slugify(slugSource);
+ const hash = hashKey(key);
+ const id = `${slug}-${hash}`;
+ const directory = join(PROJECTS_MEMORY_DIRECTORY, id);
+
+ return {
+ id,
+ slug,
+ hash,
+ key,
+ gitRoot,
+ ...(gitRemote ? { gitRemote } : {}),
+ directory,
+ };
+}
+
+function readBoundedMarkdown(path: string): string | null {
+ if (!existsSync(path)) {
+ return null;
+ }
+
+ try {
+ const content = readFileSync(path, 'utf8');
+ const boundedBytes = content.slice(0, MAX_MEMORY_BYTES);
+ return boundedBytes
+ .split('\n')
+ .slice(0, MAX_MEMORY_LINES)
+ .join('\n')
+ .trim();
+ } catch {
+ return null;
+ }
+}
+
+function ensureMemoryFile(path: string, title: string): void {
+ if (existsSync(path)) {
+ return;
+ }
+
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, `# ${title}\n\n`, 'utf8');
+}
+
+function writeProjectMetadata(paths: MemoryPaths): void {
+ if (existsSync(paths.projectMetadataPath)) {
+ return;
+ }
+
+ mkdirSync(paths.project.directory, { recursive: true });
+ writeFileSync(
+ paths.projectMetadataPath,
+ JSON.stringify(
+ {
+ id: paths.project.id,
+ name: paths.project.slug,
+ gitRoot: paths.project.gitRoot,
+ ...(paths.project.gitRemote
+ ? { gitRemote: paths.project.gitRemote }
+ : {}),
+ createdAt: new Date().toISOString(),
+ },
+ null,
+ 2,
+ ) + '\n',
+ 'utf8',
+ );
+}
+
+export function getMemoryPaths(cwd = process.cwd()): MemoryPaths {
+ const project = resolveProjectIdentity(cwd);
+
+ return {
+ globalDirectory: GLOBAL_MEMORY_DIRECTORY,
+ globalMemoryPath: join(GLOBAL_MEMORY_DIRECTORY, MEMORY_FILE),
+ project,
+ projectMemoryPath: join(project.directory, MEMORY_FILE),
+ projectMetadataPath: join(project.directory, METADATA_FILE),
+ };
+}
+
+export function getMemoryPathSummary(cwd = process.cwd()): string {
+ const paths = getMemoryPaths(cwd);
+
+ return [
+ `Global memory: ${paths.globalMemoryPath}`,
+ `Project memory: ${paths.projectMemoryPath}`,
+ ].join('\n');
+}
+
+export function loadMemoryForPrompt(cwd = process.cwd()): string | null {
+ const paths = getMemoryPaths(cwd);
+ const globalMemory = readBoundedMarkdown(paths.globalMemoryPath);
+ const projectMemory = readBoundedMarkdown(paths.projectMemoryPath);
+
+ if (!globalMemory && !projectMemory) {
+ return null;
+ }
+
+ return [
+ 'Loaded memory:',
+ 'These are durable user and project notes. Treat them as context, not hard configuration. Topic files referenced from MEMORY.md are not loaded unless you read them explicitly.',
+ globalMemory ? `--- Global memory ---\n${globalMemory}` : '',
+ projectMemory
+ ? `--- Project memory (${paths.project.id}) ---\n${projectMemory}`
+ : '',
+ ]
+ .filter(Boolean)
+ .join('\n\n');
+}
+
+export function showMemory(cwd = process.cwd()): string {
+ return loadMemoryForPrompt(cwd) ?? 'No memory found.';
+}
+
+export function appendMemory(
+ text: string,
+ options: { cwd?: string; scope?: 'global' | 'project' } = {},
+): string {
+ const normalizedText = text.trim();
+ if (!normalizedText) {
+ throw new Error('Memory text is required.');
+ }
+
+ const paths = getMemoryPaths(options.cwd);
+ const isGlobal = options.scope === 'global';
+ const memoryPath = isGlobal
+ ? paths.globalMemoryPath
+ : paths.projectMemoryPath;
+
+ ensureMemoryFile(memoryPath, isGlobal ? 'Global Memory' : 'Project Memory');
+
+ if (!isGlobal) {
+ writeProjectMetadata(paths);
+ }
+
+ appendFileSync(memoryPath, `- ${normalizedText}\n`, 'utf8');
+
+ return memoryPath;
+}
+
+export const MEMORY_LIMITS = {
+ maxLines: MAX_MEMORY_LINES,
+ maxBytes: MAX_MEMORY_BYTES,
+};
+
+export const MEMORY_TEST_ONLY = {
+ normalizeRemote,
+ slugify,
+ hashKey,
+};
diff --git a/src/utils/skills.test.ts b/src/utils/skills.test.ts
index ce489f9b..410fb3d9 100644
--- a/src/utils/skills.test.ts
+++ b/src/utils/skills.test.ts
@@ -375,6 +375,32 @@ describe('skills', () => {
expect(formatSkillsForPrompt([])).toBeNull();
});
+ it('formats a skill without a description', () => {
+ expect(
+ formatSkillsForPrompt([
+ {
+ name: 'review',
+ source: SkillSource.Project,
+ content: 'Review pull requests\n',
+ path: '/test/.code-ollama/skills/review',
+ isDisabled: false,
+ },
+ ]),
+ ).toBe(
+ [
+ 'Loaded skills:',
+ 'These are additional instructions. Follow any skill when it applies to the user request.',
+ 'Do not invent skill capabilities or descriptions. If the user asks what skills are loaded or what a skill says, answer only from the names, sources, and contents below.',
+ '--- Skill: review (project) ---\nReview pull requests',
+ ].join('\n\n'),
+ );
+ });
+
+ it('uses default directories when called with no options', () => {
+ const skills = loadSkills();
+ expect(Array.isArray(skills)).toBe(true);
+ });
+
it('marks disabled skills with isDisabled flag', () => {
const projectDirectory = join(tempRoot, 'project');
mkdirSync(projectDirectory);