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
69 changes: 54 additions & 15 deletions docs/guides/backup-restore.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Backup & Restore

Causantic supports encrypted exports for secure backup and migration of your memory data.
Causantic supports encrypted, compressed exports for secure backup and migration of your memory data.

## Export Memory

Expand All @@ -25,6 +25,7 @@ npx causantic export --output backup.json --no-encrypt

```bash
npx causantic export --output backup.causantic --projects my-project
npx causantic export --output backup.causantic --projects project-a,project-b
```

### With Redaction (for sharing)
Expand All @@ -34,6 +35,14 @@ npx causantic export --output backup.causantic --projects my-project
npx causantic export --output backup.causantic --redact-paths --redact-code
```

### Without Vectors (lightweight)

```bash
# Skip vector embeddings for a smaller file
# Note: semantic search will not work after import until re-embedding
npx causantic export --output backup.causantic --no-vectors
```

## Import Memory

### Encrypted Archive
Expand All @@ -54,6 +63,12 @@ npx causantic import backup.causantic --merge

Without `--merge`, existing data is replaced.

### Dry Run (validate without importing)

```bash
npx causantic import backup.causantic --dry-run
```

## Environment Variable (CI/Scripts)

For non-interactive environments, set the password via environment variable:
Expand All @@ -71,10 +86,26 @@ CAUSANTIC_EXPORT_PASSWORD="your-secure-password" npx causantic import backup.cau
| Data | Description |
|------|-------------|
| Chunks | Conversation segments with semantic content |
| Edges | Causal relationships (backward/forward links) |
| Clusters | Topic groupings from HDBSCAN clustering |
| Edges | Causal relationships (forward/backward links) with identity and link counts |
| Clusters | Topic groupings with centroids, exemplar IDs, distances, and membership hashes |
| Vectors | Embedding vectors for semantic search (skip with `--no-vectors`) |

## Archive Format

### Version History

## Encryption Details
| Version | Changes |
|---------|---------|
| 1.1 | Added vector embeddings, full cluster data (centroid, distances, exemplars), gzip compression, edge identity |
| 1.0 | Initial format (chunks, edges, basic clusters) |

Archives are backward-compatible: v1.1 can import v1.0 archives (with a warning that vectors are missing).

### Compression

All v1.1 exports are gzip-compressed. On import, Causantic auto-detects compressed, encrypted, and plain JSON formats.

### Encryption Details

Causantic uses strong encryption for archive files:

Expand All @@ -83,33 +114,35 @@ Causantic uses strong encryption for archive files:
- **Nonce**: 12 bytes (random per encryption)
- **Salt**: 16 bytes (unique per password)

The archive format uses magic bytes (`Causantic\0`) to identify encrypted files.

## File Formats
The archive format uses magic bytes (`CST\0`) to identify encrypted files.

### Encrypted (.causantic)
### File Structure

Binary format with structure:
**Encrypted + compressed:**
```
[Magic: 4 bytes "Causantic\0"]
[Magic: 4 bytes "CST\0"]
[Salt: 16 bytes]
[Nonce: 12 bytes]
[Auth Tag: 16 bytes]
[Ciphertext: variable]
[Ciphertext: gzip(JSON) encrypted with AES-256-GCM]
```

### Unencrypted (.json)
**Unencrypted compressed (default):**
```
[gzip(JSON)]
```

Standard JSON with structure:
**Plain JSON (v1.0 backward compat):**
```json
{
"format": "causantic-archive",
"version": "1.0",
"version": "1.1",
"created": "2024-01-15T10:30:00Z",
"metadata": { ... },
"chunks": [ ... ],
"edges": [ ... ],
"clusters": [ ... ]
"clusters": [ ... ],
"vectors": [ ... ]
}
```

Expand Down Expand Up @@ -163,3 +196,9 @@ The file is not a valid Causantic archive. Check that:
### "Decryption failed"

Wrong password. Re-enter the password carefully.

### "Archive version 1.0: no vector embeddings"

The archive was created with v1.0 (before vector support). After import:
- Semantic search (`recall`, `search`, `predict`) won't work until vectors are regenerated
- Run `npx causantic maintenance run scan-projects` to re-ingest and generate embeddings
25 changes: 16 additions & 9 deletions docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ npx causantic encryption audit 20

### export

Export memory data.
Export memory data. Archives are gzip-compressed by default and include vector embeddings for semantic search continuity.

```bash
npx causantic export [options]
Expand All @@ -251,19 +251,25 @@ npx causantic export [options]

| Option | Description |
|--------|-------------|
| `--output <path>` | Output file path |
| `--output <path>` | Output file path (default: `causantic-backup.causantic`) |
| `--no-encrypt` | Skip encryption |
| `--format <fmt>` | Output format (json, archive) |
| `--projects <slugs>` | Comma-separated project slugs to export |
| `--redact-paths` | Redact file paths in content |
| `--redact-code` | Redact code blocks in content |
| `--no-vectors` | Skip vector embeddings (smaller file, but semantic search won't work after import) |

**Example**:
```bash
npx causantic export --output backup.causantic.json
npx causantic export --output backup.causantic
npx causantic export --output backup.json --no-encrypt
npx causantic export --projects my-project,other-project --no-encrypt --output filtered.json
npx causantic export --redact-paths --redact-code --output sanitized.causantic
npx causantic export --no-vectors --output lightweight.causantic
```

### import

Import memory data.
Import memory data. Supports encrypted, compressed, and plain JSON archives.

```bash
npx causantic import <file> [options]
Expand All @@ -273,13 +279,14 @@ npx causantic import <file> [options]

| Option | Description |
|--------|-------------|
| `--merge` | Merge with existing data |
| `--replace` | Replace existing data |
| `--merge` | Merge with existing data (default: replace) |
| `--dry-run` | Validate and report without importing |

**Example**:
```bash
npx causantic import backup.causantic.json
npx causantic import backup.causantic.json --merge
npx causantic import backup.causantic
npx causantic import backup.causantic --merge
npx causantic import backup.causantic --dry-run
```

### stats
Expand Down
67 changes: 60 additions & 7 deletions src/cli/commands/archive.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import type { Command } from '../types.js';
import { promptPassword, isEncryptedArchive } from '../utils.js';

function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function formatCount(n: number): string {
return n.toLocaleString();
}

export const exportCommand: Command = {
name: 'export',
description: 'Export memory data',
usage: 'causantic export --output <path> [--no-encrypt]',
usage:
'causantic export --output <path> [--no-encrypt] [--projects <slugs>] [--redact-paths] [--redact-code] [--no-vectors]',
handler: async (args) => {
const { exportArchive } = await import('../../storage/archive.js');
const outputIndex = args.indexOf('--output');
const outputPath = outputIndex >= 0 ? args[outputIndex + 1] : 'causantic-backup.causantic';
const noEncrypt = args.includes('--no-encrypt');
const noVectors = args.includes('--no-vectors');
const redactPaths = args.includes('--redact-paths');
const redactCode = args.includes('--redact-code');

// Parse --projects flag
const projectsIndex = args.indexOf('--projects');
const projects =
projectsIndex >= 0 && args[projectsIndex + 1]
? args[projectsIndex + 1].split(',').map((s) => s.trim())
: undefined;

let password: string | undefined;
if (!noEncrypt) {
Expand All @@ -34,26 +55,45 @@ export const exportCommand: Command = {
}
}

await exportArchive({
const result = await exportArchive({
outputPath,
password,
projects,
redactPaths,
redactCode,
noVectors,
});
console.log(`Exported to ${outputPath}`);

const parts = [
`${formatCount(result.chunkCount)} chunks`,
`${formatCount(result.edgeCount)} edges`,
`${formatCount(result.clusterCount)} clusters`,
`${formatCount(result.vectorCount)} vectors`,
];
const suffix = [result.compressed ? 'compressed' : null, result.encrypted ? 'encrypted' : null]
.filter(Boolean)
.join(', ');

console.log(`Exported: ${parts.join(', ')} (${formatSize(result.fileSize)} ${suffix})`);
console.log(`File: ${outputPath}`);
},
};

export const importCommand: Command = {
name: 'import',
description: 'Import memory data',
usage: 'causantic import <file> [--merge]',
usage: 'causantic import <file> [--merge] [--dry-run]',
handler: async (args) => {
if (args.length === 0) {
console.error('Error: File path required');
process.exit(2);
}
const { importArchive } = await import('../../storage/archive.js');
const inputPath = args[0];

// Find file path (first arg that isn't a flag)
const inputPath = args.find((a) => !a.startsWith('--'))!;
const merge = args.includes('--merge');
const dryRun = args.includes('--dry-run');

const encrypted = await isEncryptedArchive(inputPath);

Expand All @@ -75,11 +115,24 @@ export const importCommand: Command = {
}
}

await importArchive({
const result = await importArchive({
inputPath,
password,
merge,
dryRun,
});
console.log('Import complete.');

const parts = [
`${formatCount(result.chunkCount)} chunks`,
`${formatCount(result.edgeCount)} edges`,
`${formatCount(result.clusterCount)} clusters`,
`${formatCount(result.vectorCount)} vectors`,
];

if (result.dryRun) {
console.log(`Dry run — would import: ${parts.join(', ')}`);
} else {
console.log(`Imported: ${parts.join(', ')}`);
}
},
};
Loading