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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Preferences follow the same location hierarchy as configuration files:
| `trustedDirectories` | Directories you've approved through the first-run security disclaimer |
| `lastUpdateCheck` | Timestamp of the last update check (used to avoid checking too frequently) |
| `semanticMemoryEnabled` | Enables semantic memory across sessions. Set to `false` or use `/settings` → **Advanced** → **Semantic Memory** to keep agents stateless. |
| `semanticMemoryTokenBudget` | Approximate token ceiling for the recalled `## Project Context` block. Default `240`, clamped to 40-4000. Adjustable from `/settings` → **Advanced**. |
| `semanticMemoryLimit` | Maximum memories considered for a single prompt. Default `8`, clamped to 1-50. Adjustable from `/settings` → **Advanced**. |

### Paste Configuration

Expand Down
73 changes: 57 additions & 16 deletions docs/features/semantic-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,35 @@ sidebar_order: 13

# Semantic Memory

Semantic memory lets you save durable facts about a project architectural decisions, conventions, known issues, rejected approaches so you don't have to re-explain them every session. Relevant memories are automatically recalled and injected into the system prompt as project context.
Semantic memory lets you save durable facts about a project - architectural decisions, conventions, known issues, rejected approaches - so you don't have to re-explain them every session. Relevant memories are automatically recalled and injected into the system prompt as project context.

Memory creation is always manual and explicit. Nothing is ever saved automatically after a session; you decide what's worth remembering.

## Commands

- `/remember [--category <name>] <content>` Save a memory directly
- `/memory list` List all saved memories with their IDs and categories
- `/memory delete <id>` Delete a specific memory
- `/memory clear` Delete all memories for the current project
- `/memory propose` Scan the current conversation for durable-sounding facts and print them as numbered proposals for review
- `/memory accept <n>` Save proposal `n` from the most recent `/memory propose` output
- `/remember [--category <name>] <content>` - Save a memory directly. `-c` is a short form of `--category`.
- `/memory list` - List all saved memories with their short IDs and categories. `/memory ls` is an alias, and a bare `/memory` with no subcommand does the same thing.
- `/memory delete <id>` - Delete a specific memory. `/memory rm` is an alias.
- `/memory clear` - Delete all memories for the current project.
- `/memory propose` - Scan the recent conversation for durable-sounding facts and print them as numbered proposals for review.
- `/memory accept <n>` - Save proposal `n` from the most recent `/memory propose` output.

### Example

```
/remember The auth module uses Clerk and avoids middleware in the edge runtime.
/remember --category codingStyle Use camelCase for all variable names.
/remember -c codingStyle Use camelCase for all variable names.

/memory list
/memory delete 18d51c0d
/memory propose
/memory accept 2
```

### Memory IDs

`/memory list` prints an 8-character short ID for each memory, which is what you pass to `/memory delete`. The full UUID still works, as does any unambiguous prefix of either. If a prefix matches more than one memory, the command reports the ambiguity and deletes nothing rather than guessing.

## Categories

Memories are grouped into: `architecture`, `bugFix`, `refactor`, `todo`, `codingStyle`, or `project` (the default, for anything that doesn't match a more specific category). `/remember` infers a category automatically from the content unless you pass `--category`.
Expand All @@ -38,26 +43,62 @@ Memories are grouped into: `architecture`, `bugFix`, `refactor`, `todo`, `coding

When you send a message, Nanocoder ranks saved memories by relevance to that message (keyword overlap, with common words filtered out) and injects the most relevant ones into the system prompt under a `## Project Context` heading, up to a token budget. Low-relevance memories are dropped rather than injected as noise.

Recall works the same way across every interface: the interactive TUI, `nanocoder run` / `--plain`, and `--acp`. Each surface shows a `Recalling N project memories...` notice when memories are injected.
Retrieval is keyword-based, not a true embeddings/vector search. The "semantic" in the name refers to the kind of facts stored (durable project knowledge), not the matching technique.

### Where recall is active

Recall runs on the interactive TUI, on `nanocoder run` / `--plain`, and on `--acp`. Each of those shows a `Recalling N project memories...` notice when memories are injected.

Recall does **not** currently run for subagent runs or for daemon-triggered skill runs. This is a deliberate limitation rather than an oversight: those runs are non-interactive, so nobody is present to notice a bad memory steering the run, and the failure mode is silent. Wiring recall into them is tracked as follow-up work, not shipped behaviour.

### Tuning the budget

Two settings bound how much of the context window project context may consume. Both are adjustable from `/settings` -> **Advanced**, which cycles through common presets, or by editing `nanocoder-preferences.json` directly for any value in range.

| Preference key | Default | Range | Meaning |
|---|---|---|---|
| `semanticMemoryEnabled` | `true` | boolean | Master switch for recall and writes |
| `semanticMemoryTokenBudget` | `240` | 40 - 4000 | Approximate token ceiling for the injected block |
| `semanticMemoryLimit` | `8` | 1 - 50 | Maximum memories considered for one prompt |

Retrieval is keyword-based, not a true embeddings/vector search — the "semantic" in the name refers to the kind of facts stored (durable project knowledge), not the matching technique.
Values outside the supported range are clamped rather than rejected. On a small local model the 240-token default is a meaningful slice of the window, so lowering it is often the right call.

## Proposals

`/memory propose` looks back through the conversation for lines that read like durable facts (matched against the category keywords above) and prints them with their source (`explicit-user` or `conversation-inferred`) and a short evidence snippet. Nothing is saved until you run `/memory accept <n>`.
`/memory propose` looks back through the recent conversation for lines that read like durable facts (matched against the category keywords above) and prints them with their source (`explicit-user` or `conversation-inferred`) and a short evidence snippet. Nothing is saved until you run `/memory accept <n>`.

Proposals inferred purely from assistant text carry an `Inferred from conversation, no explicit user statement.` warning. If the assistant appears to be capitulating to pushback rather than stating a fact (opens with "you're right", "fair enough", etc., in response to a non-technical user message), the proposal is also flagged `Possible assistant position reversal.` — this catches the case where a model agreeing with a user's stylistic preference gets summarized into a "project convention" that was never actually decided. If you explicitly restate the same line yourself, the reversal warning is cleared, since your own statement is what actually resolves the ambiguity.
The scan covers the last 40 messages and prints at most 20 proposals, so a long session doesn't produce a list too large to review. Proposals without warnings are listed first. The printed numbering is fixed for as long as that list stands: accepting one proposal does not renumber the others, and accepting the same number twice is refused rather than repeated. Running `/clear` discards the list, since its evidence refers to a conversation you can no longer see.

### Warnings

Proposals inferred purely from assistant text carry an `Inferred from conversation, no explicit user statement.` warning.

A proposal is additionally flagged `Possible assistant position reversal.` when the assistant turn looks like a concession to pressure rather than to evidence. That means the turn was preceded by a user message carrying no code, file path or error output, and the turn either contradicts an earlier assistant turn on the same subject or opens with an agreement phrase. Tool-call turns between the two are stepped over, so the check still works in a normal agentic session where the assistant reads files between turns.

This catches the case where a model agreeing with a user's stylistic preference gets summarized into a "project convention" that was never actually decided. If you explicitly restate the same line yourself, the reversal warning is cleared, since your own statement is what actually resolves the ambiguity.

The check is a heuristic tuned to over-flag rather than miss: it only adds a warning to a proposal you are already reviewing by hand, so a spurious warning costs you a moment's attention while a missed one costs you a false project convention.

## Turning It Off

Semantic memory is on by default. Toggle it from `/settings` **Semantic Memory**. Turning it off disables both recall (memories are no longer injected into prompts) and writes (`/remember` and `/memory accept` are refused while it's off).
Semantic memory is on by default. Toggle it from `/settings` -> **Advanced** -> **Semantic Memory**. Turning it off disables both recall (memories are no longer injected into prompts) and writes (`/remember` and `/memory accept` are refused while it's off).

## Storage and Scope

Memories are stored per-repository in a local JSON file under your app data directory, keyed by a hash of the repository's `git remote origin.url` (or its absolute path, for non-git directories). This means:
Memories are stored per-repository in a local JSON file under the Nanocoder data directory:

| Platform | Path |
|---|---|
| macOS | `~/Library/Application Support/nanocoder/memory/` |
| Linux | `~/.local/share/nanocoder/memory/` (or `$XDG_DATA_HOME/nanocoder/memory/`) |
| Windows | `%APPDATA%\nanocoder\memory\` |

Setting `NANOCODER_DATA_DIR` overrides all of these.

The filename is a hash of the repository's `git remote origin.url`, or of its absolute path for non-git directories. This means:

- All branches, worktrees, and local clones that share the same `origin` remote share one memory pool.
- Forks with a different `origin` get their own, separate pool.
- This scope isn't currently configurable — if you work across branches with genuinely divergent conventions in the same repository, they'll share memories.
- This scope isn't currently configurable. If you work across branches with genuinely divergent conventions in the same repository, they'll share memories.

Files are written atomically (temp file + rename) with restrictive permissions (`0600` on the file, `0700` on the directory), and nothing ever leaves your machine.
Files are written atomically (temp file + rename) with restrictive permissions (`0600` on the file, `0700` on the directory), and nothing ever leaves your machine. Memory content is fenced when injected into the system prompt, with the fence widened as needed so content containing backticks cannot break out of it.
4 changes: 2 additions & 2 deletions source/acp/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import type {AcpInitContext} from '@/acp/acp-types';
import {appendToolDefinitionsToPrompt} from '@/ai-sdk-client/tools/system-prompt-assembler';
import {getAppConfig} from '@/config/index';
import {
getSemanticMemoryEnabled,
getProjectContextPreferences,
loadPreferences,
updateLastUsed,
} from '@/config/preferences';
Expand Down Expand Up @@ -160,7 +160,7 @@ export class AcpAgent implements Agent {
session.baseSystemMessage.content,
userText,
new SemanticMemoryManager({cwd: session.cwd}),
{semanticMemoryEnabled: getSemanticMemoryEnabled()},
getProjectContextPreferences(),
);
session.systemMessage = {
role: 'system',
Expand Down
66 changes: 57 additions & 9 deletions source/app/components/settings-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
getNotificationsPreference,
getPasteThreshold,
getPrivacyPreference,
getProjectContextPreferences,
getReasoningExpanded,
getSemanticMemoryEnabled,
updateCompactToolDisplay,
updateNanocoderShape,
updateNotificationsPreference,
Expand All @@ -22,6 +22,8 @@ import {
updateReasoningExpanded,
updateSelectedTheme,
updateSemanticMemoryEnabled,
updateSemanticMemoryLimit,
updateSemanticMemoryTokenBudget,
} from '@/config/preferences';
import {getThemeColors, themes} from '@/config/themes';
import {useResponsiveTerminal} from '@/hooks/useTerminalWidth';
Expand Down Expand Up @@ -1045,6 +1047,18 @@ export function SettingsSelector({onCancel}: SettingsSelectorProps) {
}
}

/** Presets cycled by the Advanced panel. Any value in range can still be set
* directly in nanocoder-preferences.json; these are just the common choices. */
const TOKEN_BUDGET_PRESETS = [120, 240, 480, 960];
const MEMORY_LIMIT_PRESETS = [3, 5, 8, 12];

/** Next preset after `current`, wrapping. Falls to the first when `current`
* is a hand-edited value that isn't in the list. */
function cyclePreset(presets: number[], current: number): number {
const index = presets.indexOf(current);
return presets[(index + 1) % presets.length] ?? presets[0] ?? current;
}

// Advanced settings panel
function SettingsAdvancedPanel({
onBack,
Expand All @@ -1056,8 +1070,15 @@ function SettingsAdvancedPanel({
const {boxWidth, isNarrow} = useResponsiveTerminal();
const {colors} = useTheme();

const initialContextPreferences = getProjectContextPreferences();
const [semanticMemoryEnabled, setSemanticMemoryEnabled] = useState(
getSemanticMemoryEnabled(),
initialContextPreferences.semanticMemoryEnabled,
);
const [tokenBudget, setTokenBudget] = useState(
initialContextPreferences.tokenBudget,
);
const [memoryLimit, setMemoryLimit] = useState(
initialContextPreferences.memoryLimit,
);

useInput((_, key) => {
Expand All @@ -1075,13 +1096,38 @@ function SettingsAdvancedPanel({
label: `Semantic Memory: ${semanticMemoryEnabled ? 'ON' : 'OFF'}`,
value: 'semantic-memory',
},
{
label: `Memory Token Budget: ${tokenBudget}`,
value: 'semantic-memory-token-budget',
},
{
label: `Memories Per Prompt: ${memoryLimit}`,
value: 'semantic-memory-limit',
},
];
}, [semanticMemoryEnabled]);

const handleSelect = () => {
const next = !semanticMemoryEnabled;
setSemanticMemoryEnabled(next);
updateSemanticMemoryEnabled(next);
}, [semanticMemoryEnabled, tokenBudget, memoryLimit]);

const handleSelect = (item: {value: string}) => {
switch (item.value) {
case 'semantic-memory': {
const next = !semanticMemoryEnabled;
setSemanticMemoryEnabled(next);
updateSemanticMemoryEnabled(next);
break;
}
case 'semantic-memory-token-budget': {
const next = cyclePreset(TOKEN_BUDGET_PRESETS, tokenBudget);
setTokenBudget(next);
updateSemanticMemoryTokenBudget(next);
break;
}
case 'semantic-memory-limit': {
const next = cyclePreset(MEMORY_LIMIT_PRESETS, memoryLimit);
setMemoryLimit(next);
updateSemanticMemoryLimit(next);
break;
}
}
};

const title = isNarrow ? 'Advanced' : 'Advanced Settings';
Expand All @@ -1107,7 +1153,9 @@ function SettingsAdvancedPanel({
<Box marginBottom={1}>
<Text color={colors.warning}>
Semantic Memory recalls saved project context and injects it into
future prompts. Turn it off for stateless agent behavior.
future prompts. Turn it off for stateless agent behavior. The budget
and per-prompt count bound how much of the context window it may
consume - lower them on small local models.
</Text>
</Box>

Expand Down
4 changes: 4 additions & 0 deletions source/app/utils/app-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {CodexLogin} from '@/commands/codex-login';
import {CopilotLogin} from '@/commands/copilot-login';
import BashProgress from '@/components/bash-progress';
import {DELAY_COMMAND_COMPLETE_MS, MAX_SESSION_NAME_LENGTH} from '@/constants';
import {sharedProposalStore} from '@/memory/proposal-store';
import {CheckpointManager} from '@/services/checkpoint-manager';
import {generateKey} from '@/session/key-generator';
import {executeBashCommand, formatBashResultForLLM} from '@/tools/execute-bash';
Expand Down Expand Up @@ -274,6 +275,9 @@ async function handleSpecialCommand(
case SPECIAL_COMMANDS.CLEAR:
await onClearMessages();
await clearAllTasks();
// Proposals reference a conversation that no longer exists; accepting
// one after /clear would save evidence the user can no longer see.
sharedProposalStore.clear();
onAddToChatQueue(successMsg('Chat and tasks cleared.', 'clear-success'));
setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS);
return true;
Expand Down
Loading