Skip to content
Open
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
50 changes: 43 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,54 @@ For local development, use the `file://` protocol:
}
```

### Environment Variables

You can customize save locations via environment variables:

| Variable | Description | Default |
|---|---|---|
| `OPENCODE_AUTOSAVE_DIR` | Local save directory (relative to project root, or absolute path). Set to `false` to disable local saves. | `./conversations` |
| `OPENCODE_AUTOSAVE_GLOBAL_DIR` | Global (secondary) save base directory. The project name is appended as a subdirectory automatically. Set to `false` to disable global saves. | `~/.conversations` |

**Examples:**

Save only to a global `~/Notes/conversations/` directory (no local project copies):

```bash
OPENCODE_AUTOSAVE_DIR=false \
OPENCODE_AUTOSAVE_GLOBAL_DIR=~/Notes/conversations \
opencode
```

Save locally to a custom directory:

```bash
OPENCODE_AUTOSAVE_DIR=.opencode/history \
opencode
```

Disable all saving:

```bash
OPENCODE_AUTOSAVE_DIR=false \
OPENCODE_AUTOSAVE_GLOBAL_DIR=false \
opencode
```

You can set these permanently in your shell profile (e.g. `~/.zshrc`, `~/.bashrc`).

## Usage

Once installed, the plugin automatically:

1. Creates a `./conversations/` directory in your project
1. Creates a `./conversations/` directory in your project (unless disabled via `OPENCODE_AUTOSAVE_DIR=false`)
2. Creates a new markdown file when you start a conversation
3. Saves all messages when the session becomes idle (2 second debounce)
4. Saves images to `./conversations/images/` directory
5. Includes child session (subagent) content inline in the parent file
6. **Mirrors all saves to `~/.conversations/{project_name}/`** for global backup
6. **Mirrors all saves to `~/.conversations/{project_name}/`** for global backup (configurable via `OPENCODE_AUTOSAVE_GLOBAL_DIR`)

No configuration needed - just install and start chatting!
No configuration needed - just install and start chatting! Customize save locations with environment variables if desired.

## File Naming

Expand Down Expand Up @@ -134,9 +170,9 @@ The implementation looks good...

## Directory Structure

Conversations are saved to two locations:
Conversations are saved to two locations (both configurable):

**Primary (project-local):**
**Primary (project-local) — `OPENCODE_AUTOSAVE_DIR`:**
```
your-project/
├── conversations/
Expand All @@ -148,7 +184,7 @@ your-project/
└── ...
```

**Global backup (`~/.conversations/{project_name}/`):**
**Global backup — `OPENCODE_AUTOSAVE_GLOBAL_DIR`:**
```
~/.conversations/
└── your-project/
Expand All @@ -159,7 +195,7 @@ your-project/
└── 20250129-14-22-30-fix-bug.md
```

Both locations contain identical content. The global backup allows you to access all your conversations across different projects from a single location.
Both locations contain identical content. The global backup allows you to access all your conversations across different projects from a single location. Either location can be disabled independently.

## Troubleshooting

Expand Down
10 changes: 8 additions & 2 deletions src/file-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ export async function saveImageFromBase64(
}
}

export function getGlobalSaveDirectory(projectDir: string): string | null {
export function getGlobalSaveDirectory(projectDir: string, config: PluginConfig): string | null {
if (!config.globalEnabled) {
return null;
}

try {
const home = homedir();
if (!home) {
Expand All @@ -158,7 +162,9 @@ export function getGlobalSaveDirectory(projectDir: string): string | null {
const projectName = basename(projectDir);
const sanitizedProjectName = sanitizeTopic(projectName, 50);

return join(home, '.conversations', sanitizedProjectName);
// Use configured global directory, or fall back to ~/.conversations
const baseDir = config.globalSaveDirectory || join(home, '.conversations');
return join(baseDir, sanitizedProjectName);
} catch {
return null;
}
Expand Down
30 changes: 22 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,22 @@ const plugin: Plugin = async (input) => {
const { client, directory } = input;
const typedClient = client as unknown as OpencodeClient;

const saveDir = await ensureDirectory(directory, DEFAULT_CONFIG);
let saveDir: string | null = null;
if (DEFAULT_CONFIG.localEnabled) {
saveDir = await ensureDirectory(directory, DEFAULT_CONFIG);
}

let globalSaveDir: string | null = null;
const globalPath = getGlobalSaveDirectory(directory);
const globalPath = getGlobalSaveDirectory(directory, DEFAULT_CONFIG);
if (globalPath) {
globalSaveDir = await ensureGlobalDirectory(globalPath);
}

// If both save locations are disabled, nothing to do
if (!saveDir && !globalSaveDir) {
return {};
}

const hooks: Hooks = {
event: async ({ event }: { event: Event }) => {
try {
Expand All @@ -132,7 +140,7 @@ async function handleEvent(
event: Event,
client: OpencodeClient,
directory: string,
saveDir: string,
saveDir: string | null,
globalSaveDir: string | null
): Promise<void> {
switch (event.type) {
Expand Down Expand Up @@ -181,7 +189,7 @@ function handleSessionIdle(
event: SessionIdleEvent,
client: OpencodeClient,
directory: string,
saveDir: string,
saveDir: string | null,
globalSaveDir: string | null
): void {
const { sessionID } = event.properties;
Expand All @@ -205,7 +213,7 @@ async function handleSessionDeleted(
event: SessionDeletedEvent,
client: OpencodeClient,
directory: string,
saveDir: string,
saveDir: string | null,
globalSaveDir: string | null
): Promise<void> {
const { info } = event.properties;
Expand All @@ -225,7 +233,7 @@ async function saveSessionToFile(
sessionID: string,
client: OpencodeClient,
directory: string,
saveDir: string,
saveDir: string | null,
globalSaveDir: string | null
): Promise<void> {
try {
Expand Down Expand Up @@ -272,7 +280,10 @@ async function saveSessionToFile(

if (!session.filePath) {
const filename = generateFilename(title || 'untitled', session.createdAt, DEFAULT_CONFIG);
session.filePath = join(saveDir, filename);
// Use local save dir if available, otherwise use global dir for file path reference
const baseDir = saveDir || globalSaveDir;
if (!baseDir) return;
session.filePath = join(baseDir, filename);
}

const children = getChildSessions(sessionID);
Expand Down Expand Up @@ -302,7 +313,10 @@ async function saveSessionToFile(
messages,
childData
);
await writeSessionFile(session.filePath, content);

if (saveDir) {
await writeSessionFile(session.filePath, content);
}

if (globalSaveDir) {
await writeToSecondaryLocation(session.filePath, globalSaveDir, content);
Expand Down
57 changes: 48 additions & 9 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,67 @@ export interface FormattedMessage {

/**
* Plugin configuration options.
* V1 uses hardcoded defaults; future versions may expose these.
* Configurable via environment variables:
*
* OPENCODE_AUTOSAVE_DIR — Local save directory (relative to project root,
* or absolute path). Set to "false" to disable.
* Default: "./conversations"
*
* OPENCODE_AUTOSAVE_GLOBAL_DIR — Global (secondary) save directory. Accepts an
* absolute path. The project name is appended
* automatically as a subdirectory. Set to "false"
* to disable. Default: "~/.conversations"
*/
export interface PluginConfig {
/** Directory to save conversations (relative to project root) */
/** Directory to save conversations (relative to project root, or absolute) */
saveDirectory: string;

/** Whether local (primary) saving is enabled */
localEnabled: boolean;

/** Global save base directory (project name is appended as subdirectory) */
globalSaveDirectory: string;

/** Whether global (secondary) saving is enabled */
globalEnabled: boolean;

/** Maximum characters for topic portion of filename */
maxTopicLength: number;

/** Debounce delay for idle saves (milliseconds) */
debounceMs: number;
}

const ENV_LOCAL_DIR = 'OPENCODE_AUTOSAVE_DIR';
const ENV_GLOBAL_DIR = 'OPENCODE_AUTOSAVE_GLOBAL_DIR';

function isDisabled(value: string | undefined): boolean {
if (!value) return false;
const lower = value.trim().toLowerCase();
return lower === 'false' || lower === 'off' || lower === '0' || lower === 'no';
}

/**
* Build configuration from environment variables, falling back to defaults.
*/
export function resolveConfig(): PluginConfig {
const envLocal = process.env[ENV_LOCAL_DIR];
const envGlobal = process.env[ENV_GLOBAL_DIR];

return {
saveDirectory: (!envLocal || isDisabled(envLocal)) ? './conversations' : envLocal.trim(),
localEnabled: !isDisabled(envLocal),
globalSaveDirectory: (!envGlobal || isDisabled(envGlobal)) ? '' : envGlobal.trim(),
globalEnabled: !isDisabled(envGlobal),
maxTopicLength: 30,
debounceMs: 2000,
};
}

/**
* Default configuration values for V1.
* These are hardcoded and not user-configurable in this version.
* Default configuration values (no environment overrides).
*/
export const DEFAULT_CONFIG: PluginConfig = {
saveDirectory: './conversations',
maxTopicLength: 30,
debounceMs: 2000,
};
export const DEFAULT_CONFIG: PluginConfig = resolveConfig();

/**
* Child session data for inline embedding.
Expand Down