@aperdomoll90/ledger-ai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -0
- package/dist/cli.js +153 -0
- package/dist/commands/backup.js +89 -0
- package/dist/commands/check.js +79 -0
- package/dist/commands/config.js +115 -0
- package/dist/commands/export.js +21 -0
- package/dist/commands/ingest.js +221 -0
- package/dist/commands/init.js +142 -0
- package/dist/commands/onboard.js +190 -0
- package/dist/commands/pull.js +75 -0
- package/dist/commands/push.js +21 -0
- package/dist/commands/restore.js +72 -0
- package/dist/commands/setup.js +159 -0
- package/dist/commands/show.js +34 -0
- package/dist/hooks/block-env.sh +54 -0
- package/dist/hooks/hooks/block-env.sh +54 -0
- package/dist/hooks/hooks/post-write-ledger.sh +40 -0
- package/dist/hooks/hooks/session-end-check.sh +23 -0
- package/dist/hooks/post-write-ledger.sh +40 -0
- package/dist/hooks/session-end-check.sh +23 -0
- package/dist/lib/config.js +63 -0
- package/dist/lib/errors.js +23 -0
- package/dist/lib/generators.js +115 -0
- package/dist/lib/hash.js +4 -0
- package/dist/lib/migrate.js +23 -0
- package/dist/lib/notes.js +105 -0
- package/dist/lib/prompt.js +65 -0
- package/dist/mcp-server.js +348 -0
- package/dist/migrations/000-tracking.sql +4 -0
- package/dist/migrations/001-schema.sql +27 -0
- package/dist/migrations/002-functions.sql +14 -0
- package/dist/migrations/003-rls.sql +5 -0
- package/dist/migrations/migrations/000-tracking.sql +4 -0
- package/dist/migrations/migrations/001-schema.sql +27 -0
- package/dist/migrations/migrations/002-functions.sql +14 -0
- package/dist/migrations/migrations/003-rls.sql +5 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# @aperdomoll90/ledger-ai
|
|
2
|
+
|
|
3
|
+
Your AI, everywhere. One identity, one memory, every device.
|
|
4
|
+
|
|
5
|
+
Ledger stores who you are, how you work, and what you know in a single knowledge base. Connect any AI agent — Claude, OpenClaw, ChatGPT — and it immediately knows your rules, preferences, and context. Switch devices and pick up where you left off.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @aperdomoll90/ledger-ai
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node.js 20+.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 1. Set up credentials and database
|
|
19
|
+
ledger init
|
|
20
|
+
|
|
21
|
+
# 2. Connect your AI agent
|
|
22
|
+
ledger setup claude # Claude Code (live sync, MCP, hooks)
|
|
23
|
+
ledger setup openclaw # OpenClaw (persona files, CLI sync)
|
|
24
|
+
ledger setup chatgpt # ChatGPT (static system prompt export)
|
|
25
|
+
|
|
26
|
+
# 3. Create your persona
|
|
27
|
+
ledger onboard
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Second device? Same Ledger, same persona:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g @aperdomoll90/ledger-ai
|
|
34
|
+
ledger init # connect to existing Supabase project
|
|
35
|
+
ledger setup claude # pull persona, install hooks
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What It Does
|
|
39
|
+
|
|
40
|
+
**For you:** Define who you are, how you want AI to behave, your coding conventions, communication style. Do it once. Every AI agent on every device follows the same rules.
|
|
41
|
+
|
|
42
|
+
**For your agents:** Semantic search over your knowledge base. Notes are embedded with OpenAI and stored in Postgres with pgvector. Agents find relevant context by meaning, not keywords.
|
|
43
|
+
|
|
44
|
+
**For your workflow:** Automatic sync at session start, conflict detection, session-end checks. Hooks enforce rules (block credential file access, auto-ingest notes to Ledger).
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
| Command | Description |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `ledger init` | Set up credentials and database schema |
|
|
51
|
+
| `ledger setup <platform>` | Configure an agent (claude, openclaw, chatgpt) |
|
|
52
|
+
| `ledger onboard` | Create your persona (interactive wizard) |
|
|
53
|
+
| `ledger pull` | Download notes from Ledger to local cache |
|
|
54
|
+
| `ledger push <file>` | Upload a local file to Ledger |
|
|
55
|
+
| `ledger check` | Compare local files vs Ledger |
|
|
56
|
+
| `ledger show <query>` | Search by meaning, open matching note |
|
|
57
|
+
| `ledger export <query>` | Download a note to any path (untracked) |
|
|
58
|
+
| `ledger ingest [file]` | Add files to Ledger with duplicate detection |
|
|
59
|
+
| `ledger backup` | Backup all notes to ~/.ledger/backups/ |
|
|
60
|
+
| `ledger restore <file>` | Restore from backup |
|
|
61
|
+
| `ledger config list` | View settings |
|
|
62
|
+
| `ledger config set <key> <value>` | Change settings |
|
|
63
|
+
|
|
64
|
+
## Stack
|
|
65
|
+
|
|
66
|
+
- **Database:** Supabase (hosted Postgres + pgvector) — free tier
|
|
67
|
+
- **Embeddings:** OpenAI `text-embedding-3-small`
|
|
68
|
+
- **Protocol:** MCP (Model Context Protocol) for Claude Code
|
|
69
|
+
- **CLI:** Commander, TypeScript, ES modules
|
|
70
|
+
- **Sync:** SHA-256 content hashing, conflict detection
|
|
71
|
+
|
|
72
|
+
## How It Works
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Your devices / agents
|
|
76
|
+
|
|
|
77
|
+
v
|
|
78
|
+
ledger CLI ←→ Supabase (Postgres + pgvector)
|
|
79
|
+
|
|
|
80
|
+
├── pull: download notes → local cache files + CLAUDE.md
|
|
81
|
+
├── push: upload local changes → Ledger (re-embeds)
|
|
82
|
+
├── check: compare hashes, detect drift
|
|
83
|
+
├── show: semantic search → open in editor
|
|
84
|
+
└── MCP server: Claude Code talks to Ledger natively
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Notes are stored with content + metadata + vector embeddings. Search finds relevant notes by meaning. Sync uses SHA-256 content hashes to detect changes without markers.
|
|
88
|
+
|
|
89
|
+
## Platforms
|
|
90
|
+
|
|
91
|
+
| Platform | Connection | Sync |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| Claude Code | MCP (live) + hooks | Bidirectional, automatic |
|
|
94
|
+
| OpenClaw | CLI | Bidirectional via `ledger` commands |
|
|
95
|
+
| ChatGPT | None | Static snapshot, re-run to update |
|
|
96
|
+
|
|
97
|
+
## Requirements
|
|
98
|
+
|
|
99
|
+
- Node.js 20+
|
|
100
|
+
- Supabase project (free tier works)
|
|
101
|
+
- OpenAI API key (for embeddings, ~$0.02/1M tokens)
|
|
102
|
+
|
|
103
|
+
## Known Limitations
|
|
104
|
+
|
|
105
|
+
- **Embeddings require OpenAI** — currently uses `text-embedding-3-small` only. Supabase built-in embeddings (free, no API key) and multi-provider support planned for v2.0.
|
|
106
|
+
- **Anthropic has no embedding API** — Claude is for text generation. Even Claude users need an OpenAI key for embeddings.
|
|
107
|
+
- **English-optimized** — semantic search works best with English content. Multilingual support depends on the embedding model.
|
|
108
|
+
|
|
109
|
+
## Roadmap
|
|
110
|
+
|
|
111
|
+
- Multi-provider embeddings (Supabase built-in, OpenAI, others)
|
|
112
|
+
- Note versioning / history
|
|
113
|
+
- Soft delete (trash)
|
|
114
|
+
- Multi-format ingest (PDF, Excel, images, audio)
|
|
115
|
+
- Web dashboard
|
|
116
|
+
- VS Code extension
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
ISC
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { loadConfig } from './lib/config.js';
|
|
4
|
+
import { pull } from './commands/pull.js';
|
|
5
|
+
import { push } from './commands/push.js';
|
|
6
|
+
import { check } from './commands/check.js';
|
|
7
|
+
import { show } from './commands/show.js';
|
|
8
|
+
import { exportNote } from './commands/export.js';
|
|
9
|
+
import { ingest } from './commands/ingest.js';
|
|
10
|
+
import { init } from './commands/init.js';
|
|
11
|
+
import { setupClaudeCode, setupOpenclaw, setupChatgpt } from './commands/setup.js';
|
|
12
|
+
import { backup, enableBackupCron, disableBackupCron } from './commands/backup.js';
|
|
13
|
+
import { restore } from './commands/restore.js';
|
|
14
|
+
import { onboard } from './commands/onboard.js';
|
|
15
|
+
import { configGet, configSet, configList } from './commands/config.js';
|
|
16
|
+
process.on('unhandledRejection', (err) => {
|
|
17
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
});
|
|
20
|
+
const program = new Command();
|
|
21
|
+
program
|
|
22
|
+
.name('ledger')
|
|
23
|
+
.description('AI identity and memory system — sync knowledge across agents and devices')
|
|
24
|
+
.version('1.0.0');
|
|
25
|
+
program
|
|
26
|
+
.command('pull')
|
|
27
|
+
.description('Download notes from Ledger to local cache')
|
|
28
|
+
.option('-q, --quiet', 'suppress non-conflict output')
|
|
29
|
+
.option('-f, --force', 'overwrite local changes without conflict check')
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
const config = loadConfig();
|
|
32
|
+
await pull(config, { quiet: options.quiet ?? false, force: options.force ?? false });
|
|
33
|
+
});
|
|
34
|
+
program
|
|
35
|
+
.command('push <file>')
|
|
36
|
+
.description('Upload a local file to Ledger')
|
|
37
|
+
.action(async (file) => {
|
|
38
|
+
const config = loadConfig();
|
|
39
|
+
await push(config, file);
|
|
40
|
+
});
|
|
41
|
+
program
|
|
42
|
+
.command('check')
|
|
43
|
+
.description('Compare local files vs Ledger, report sync status')
|
|
44
|
+
.action(async () => {
|
|
45
|
+
const config = loadConfig();
|
|
46
|
+
await check(config);
|
|
47
|
+
});
|
|
48
|
+
program
|
|
49
|
+
.command('show <query...>')
|
|
50
|
+
.description('Search Ledger by meaning, open matching note')
|
|
51
|
+
.option('-t, --type <type>', 'filter by note type (e.g. feedback, reference)')
|
|
52
|
+
.option('-p, --project <project>', 'filter by project name')
|
|
53
|
+
.action(async (queryParts, options) => {
|
|
54
|
+
const config = loadConfig();
|
|
55
|
+
await show(config, queryParts.join(' '), { type: options.type, project: options.project });
|
|
56
|
+
});
|
|
57
|
+
program
|
|
58
|
+
.command('export <query...>')
|
|
59
|
+
.description('Download a note to a custom location (untracked)')
|
|
60
|
+
.option('-o, --output <path>', 'output directory (default: current directory)')
|
|
61
|
+
.action(async (queryParts, options) => {
|
|
62
|
+
const config = loadConfig();
|
|
63
|
+
await exportNote(config, queryParts.join(' '), options.output);
|
|
64
|
+
});
|
|
65
|
+
program
|
|
66
|
+
.command('ingest [file]')
|
|
67
|
+
.description('Scan for unknown files and add them to Ledger with duplicate detection')
|
|
68
|
+
.option('-a, --auto', 'auto-ingest without prompts (for hooks)')
|
|
69
|
+
.action(async (file, options) => {
|
|
70
|
+
const config = loadConfig();
|
|
71
|
+
await ingest(config, { file, auto: options.auto ?? false });
|
|
72
|
+
});
|
|
73
|
+
program
|
|
74
|
+
.command('backup')
|
|
75
|
+
.description('Backup all notes to ~/.ledger/backups/')
|
|
76
|
+
.option('-q, --quiet', 'suppress output unless error')
|
|
77
|
+
.option('--enable-cron', 'enable daily backup at 1am')
|
|
78
|
+
.option('--disable-cron', 'disable daily backup cron')
|
|
79
|
+
.action(async (options) => {
|
|
80
|
+
if (options.enableCron) {
|
|
81
|
+
enableBackupCron();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (options.disableCron) {
|
|
85
|
+
disableBackupCron();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
await backup(config, { quiet: options.quiet ?? false });
|
|
90
|
+
});
|
|
91
|
+
program
|
|
92
|
+
.command('restore <file>')
|
|
93
|
+
.description('Restore notes from a backup JSON file')
|
|
94
|
+
.action(async (file) => {
|
|
95
|
+
const config = loadConfig();
|
|
96
|
+
await restore(config, file);
|
|
97
|
+
});
|
|
98
|
+
const configCmd = program
|
|
99
|
+
.command('config')
|
|
100
|
+
.description('View or change Ledger settings');
|
|
101
|
+
configCmd
|
|
102
|
+
.command('get <key>')
|
|
103
|
+
.description('Get a config value (or "all" for full config)')
|
|
104
|
+
.action(async (key) => {
|
|
105
|
+
await configGet(key);
|
|
106
|
+
});
|
|
107
|
+
configCmd
|
|
108
|
+
.command('set <key> <value>')
|
|
109
|
+
.description('Set a config value')
|
|
110
|
+
.action(async (key, value) => {
|
|
111
|
+
await configSet(key, value);
|
|
112
|
+
});
|
|
113
|
+
configCmd
|
|
114
|
+
.command('list')
|
|
115
|
+
.description('Show all settings')
|
|
116
|
+
.action(async () => {
|
|
117
|
+
await configList();
|
|
118
|
+
});
|
|
119
|
+
program
|
|
120
|
+
.command('onboard')
|
|
121
|
+
.description('Create your AI persona (profile, communication style, rules)')
|
|
122
|
+
.action(async () => {
|
|
123
|
+
const config = loadConfig();
|
|
124
|
+
await onboard(config);
|
|
125
|
+
});
|
|
126
|
+
program
|
|
127
|
+
.command('init')
|
|
128
|
+
.description('Set up Ledger on this machine (credentials, database schema)')
|
|
129
|
+
.action(async () => {
|
|
130
|
+
await init();
|
|
131
|
+
});
|
|
132
|
+
const setupCmd = program
|
|
133
|
+
.command('setup')
|
|
134
|
+
.description('Configure an agent platform to use Ledger');
|
|
135
|
+
setupCmd
|
|
136
|
+
.command('claude-code')
|
|
137
|
+
.description('Register MCP, install hooks, pull cache (live sync)')
|
|
138
|
+
.action(async () => {
|
|
139
|
+
await setupClaudeCode();
|
|
140
|
+
});
|
|
141
|
+
setupCmd
|
|
142
|
+
.command('openclaw [path]')
|
|
143
|
+
.description('Generate persona files for OpenClaw (live sync via CLI)')
|
|
144
|
+
.action(async (path) => {
|
|
145
|
+
await setupOpenclaw(path);
|
|
146
|
+
});
|
|
147
|
+
setupCmd
|
|
148
|
+
.command('chatgpt')
|
|
149
|
+
.description('Generate system prompt for ChatGPT (static snapshot)')
|
|
150
|
+
.action(async () => {
|
|
151
|
+
await setupChatgpt();
|
|
152
|
+
});
|
|
153
|
+
program.parse();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
4
|
+
import { getLedgerDir } from '../lib/config.js';
|
|
5
|
+
export async function backup(config, options) {
|
|
6
|
+
const { quiet } = options;
|
|
7
|
+
const backupDir = resolve(getLedgerDir(), 'backups');
|
|
8
|
+
mkdirSync(backupDir, { recursive: true });
|
|
9
|
+
// Fetch all notes (not just cached)
|
|
10
|
+
const { data, error } = await config.supabase
|
|
11
|
+
.from('notes')
|
|
12
|
+
.select('id, content, metadata, created_at, updated_at')
|
|
13
|
+
.order('id', { ascending: true });
|
|
14
|
+
if (error) {
|
|
15
|
+
console.error(`Backup failed: ${error.message}`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
if (!data || data.length === 0) {
|
|
19
|
+
if (!quiet)
|
|
20
|
+
console.error('No notes to backup.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const date = new Date().toISOString().split('T')[0];
|
|
24
|
+
const filePath = resolve(backupDir, `${date}.json`);
|
|
25
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
26
|
+
// Keep last 5 backups, delete older
|
|
27
|
+
const backups = readdirSync(backupDir)
|
|
28
|
+
.filter(f => f.endsWith('.json'))
|
|
29
|
+
.sort()
|
|
30
|
+
.reverse();
|
|
31
|
+
for (const old of backups.slice(5)) {
|
|
32
|
+
unlinkSync(resolve(backupDir, old));
|
|
33
|
+
if (!quiet)
|
|
34
|
+
console.error(` deleted old backup: ${old}`);
|
|
35
|
+
}
|
|
36
|
+
if (!quiet) {
|
|
37
|
+
console.error(`Backed up ${data.length} notes to ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
console.log(filePath);
|
|
40
|
+
}
|
|
41
|
+
export function enableBackupCron() {
|
|
42
|
+
const cronLine = '0 1 * * * ledger backup --quiet';
|
|
43
|
+
// Check if already in crontab
|
|
44
|
+
let existing = '';
|
|
45
|
+
try {
|
|
46
|
+
existing = execFileSync('crontab', ['-l'], { encoding: 'utf-8' });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// No crontab yet
|
|
50
|
+
}
|
|
51
|
+
if (existing.includes('ledger backup')) {
|
|
52
|
+
console.error('Backup cron already enabled.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const newCrontab = existing.trimEnd() + '\n' + cronLine + '\n';
|
|
56
|
+
try {
|
|
57
|
+
const result = spawnSync('crontab', ['-'], { input: newCrontab, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
58
|
+
if (result.status !== 0)
|
|
59
|
+
throw new Error(result.stderr?.toString() || 'crontab failed');
|
|
60
|
+
console.error('Daily backup enabled (1am). View with `crontab -l`.');
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
console.error(`Failed to set cron: ${e.message}`);
|
|
64
|
+
console.error(`Add manually: ${cronLine}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function disableBackupCron() {
|
|
68
|
+
let existing = '';
|
|
69
|
+
try {
|
|
70
|
+
existing = execFileSync('crontab', ['-l'], { encoding: 'utf-8' });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
console.error('No crontab found.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const filtered = existing
|
|
77
|
+
.split('\n')
|
|
78
|
+
.filter(line => !line.includes('ledger backup'))
|
|
79
|
+
.join('\n');
|
|
80
|
+
try {
|
|
81
|
+
const result = spawnSync('crontab', ['-'], { input: filtered, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
82
|
+
if (result.status !== 0)
|
|
83
|
+
throw new Error(result.stderr?.toString() || 'crontab failed');
|
|
84
|
+
console.error('Backup cron disabled.');
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
console.error(`Failed to update cron: ${e.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { fetchNoteHashes } from '../lib/notes.js';
|
|
4
|
+
import { contentHash } from '../lib/hash.js';
|
|
5
|
+
export async function check(config) {
|
|
6
|
+
const result = {
|
|
7
|
+
files: [],
|
|
8
|
+
clean: 0,
|
|
9
|
+
modified: 0,
|
|
10
|
+
upstream: 0,
|
|
11
|
+
conflicts: 0,
|
|
12
|
+
unknown: 0,
|
|
13
|
+
deleted: 0,
|
|
14
|
+
};
|
|
15
|
+
if (!existsSync(config.memoryDir)) {
|
|
16
|
+
console.error('Memory directory not found. Run `ledger pull` first.');
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
const noteHashes = await fetchNoteHashes(config.supabase);
|
|
20
|
+
const notesByFile = new Map(noteHashes.map(n => [n.localFile, n]));
|
|
21
|
+
const localFiles = readdirSync(config.memoryDir)
|
|
22
|
+
.filter(f => f.endsWith('.md') && f !== 'MEMORY.md');
|
|
23
|
+
for (const file of localFiles) {
|
|
24
|
+
const filePath = resolve(config.memoryDir, file);
|
|
25
|
+
const localContent = readFileSync(filePath, 'utf-8').trim();
|
|
26
|
+
const localHash = contentHash(localContent);
|
|
27
|
+
const note = notesByFile.get(file);
|
|
28
|
+
if (!note) {
|
|
29
|
+
console.error(` ${file} — unknown (not in Ledger)`);
|
|
30
|
+
result.files.push({ file, state: 'unknown' });
|
|
31
|
+
result.unknown++;
|
|
32
|
+
notesByFile.delete(file);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const ledgerHash = contentHash(note.content);
|
|
36
|
+
const storedHash = note.contentHash;
|
|
37
|
+
const localChanged = localHash !== storedHash;
|
|
38
|
+
const ledgerChanged = ledgerHash !== storedHash;
|
|
39
|
+
if (!localChanged && !ledgerChanged) {
|
|
40
|
+
console.error(` ${file} — in sync`);
|
|
41
|
+
result.files.push({ file, state: 'clean', noteId: note.id });
|
|
42
|
+
result.clean++;
|
|
43
|
+
}
|
|
44
|
+
else if (localChanged && !ledgerChanged) {
|
|
45
|
+
console.error(` ${file} — modified locally`);
|
|
46
|
+
result.files.push({ file, state: 'modified', noteId: note.id });
|
|
47
|
+
result.modified++;
|
|
48
|
+
}
|
|
49
|
+
else if (!localChanged && ledgerChanged) {
|
|
50
|
+
console.error(` ${file} — updated in Ledger`);
|
|
51
|
+
result.files.push({ file, state: 'upstream', noteId: note.id });
|
|
52
|
+
result.upstream++;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.error(` ${file} — CONFLICT (both changed)`);
|
|
56
|
+
result.files.push({ file, state: 'conflict', noteId: note.id });
|
|
57
|
+
result.conflicts++;
|
|
58
|
+
}
|
|
59
|
+
notesByFile.delete(file);
|
|
60
|
+
}
|
|
61
|
+
for (const [file, note] of notesByFile) {
|
|
62
|
+
console.error(` ${file} — missing locally (exists in Ledger)`);
|
|
63
|
+
result.files.push({ file, state: 'deleted', noteId: note.id });
|
|
64
|
+
result.deleted++;
|
|
65
|
+
}
|
|
66
|
+
const summary = [
|
|
67
|
+
`${result.clean} clean`,
|
|
68
|
+
result.modified > 0 ? `${result.modified} modified` : null,
|
|
69
|
+
result.upstream > 0 ? `${result.upstream} upstream` : null,
|
|
70
|
+
result.conflicts > 0 ? `${result.conflicts} conflicts` : null,
|
|
71
|
+
result.unknown > 0 ? `${result.unknown} unknown` : null,
|
|
72
|
+
result.deleted > 0 ? `${result.deleted} deleted` : null,
|
|
73
|
+
].filter(Boolean).join(', ');
|
|
74
|
+
console.log(`Check: ${summary}`);
|
|
75
|
+
if (result.modified === 0 && result.conflicts === 0 && result.unknown === 0 && result.upstream === 0 && result.deleted === 0) {
|
|
76
|
+
console.log('All synced.');
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { getLedgerDir } from '../lib/config.js';
|
|
4
|
+
import { confirm } from '../lib/prompt.js';
|
|
5
|
+
const CONFIG_PATH = resolve(getLedgerDir(), 'config.json');
|
|
6
|
+
function loadFullConfig() {
|
|
7
|
+
if (existsSync(CONFIG_PATH)) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
function saveConfig(config) {
|
|
18
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
19
|
+
}
|
|
20
|
+
const SECURITY_WARNINGS = {
|
|
21
|
+
envBlocking: 'This disables .env file protection.\nYour API keys and credentials will be readable by the AI agent.',
|
|
22
|
+
mcpJsonBlocking: 'This allows direct editing of mcp.json.\nMCP servers should be registered via `claude mcp add`, not by editing config files.',
|
|
23
|
+
};
|
|
24
|
+
const DESCRIPTIONS = {
|
|
25
|
+
envBlocking: 'Block reading/writing .env and credential files',
|
|
26
|
+
mcpJsonBlocking: 'Block direct editing of mcp.json',
|
|
27
|
+
writeInterception: 'Auto-ingest files written to memory directory into Ledger',
|
|
28
|
+
sessionEndCheck: 'Check for unsynced files at session end',
|
|
29
|
+
};
|
|
30
|
+
export async function configGet(key) {
|
|
31
|
+
const config = loadFullConfig();
|
|
32
|
+
if (key === 'all') {
|
|
33
|
+
console.log(JSON.stringify(config, null, 2));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Check hooks
|
|
37
|
+
const hookKey = key;
|
|
38
|
+
if (hookKey in (config.hooks || {})) {
|
|
39
|
+
console.log(`${key}: ${config.hooks?.[hookKey]}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Check top-level
|
|
43
|
+
if (key in config) {
|
|
44
|
+
console.log(`${key}: ${config[key]}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Show default
|
|
48
|
+
const defaults = {
|
|
49
|
+
envBlocking: true,
|
|
50
|
+
mcpJsonBlocking: true,
|
|
51
|
+
writeInterception: true,
|
|
52
|
+
sessionEndCheck: true,
|
|
53
|
+
};
|
|
54
|
+
if (key in defaults) {
|
|
55
|
+
console.log(`${key}: ${defaults[key]} (default)`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
console.error(`Unknown config key: ${key}`);
|
|
59
|
+
console.error(`Available: ${Object.keys(DESCRIPTIONS).join(', ')}, memoryDir, claudeMdPath, all`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
export async function configSet(key, value) {
|
|
63
|
+
const config = loadFullConfig();
|
|
64
|
+
// Handle hook settings
|
|
65
|
+
const hookKeys = ['envBlocking', 'mcpJsonBlocking', 'writeInterception', 'sessionEndCheck'];
|
|
66
|
+
if (hookKeys.includes(key)) {
|
|
67
|
+
const boolValue = value === 'true';
|
|
68
|
+
// Disabling a security feature — warn and double confirm
|
|
69
|
+
if (!boolValue && key in SECURITY_WARNINGS) {
|
|
70
|
+
console.error(`\nWARNING: ${SECURITY_WARNINGS[key]}\n`);
|
|
71
|
+
const first = await confirm('Are you sure?');
|
|
72
|
+
if (!first) {
|
|
73
|
+
console.error('Cancelled.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const second = await confirm(`Confirm: disable ${key}?`);
|
|
77
|
+
if (!second) {
|
|
78
|
+
console.error('Cancelled.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!config.hooks)
|
|
83
|
+
config.hooks = {};
|
|
84
|
+
config.hooks[key] = boolValue;
|
|
85
|
+
saveConfig(config);
|
|
86
|
+
const state = boolValue ? 'enabled' : 'disabled';
|
|
87
|
+
console.error(`${key}: ${state}`);
|
|
88
|
+
console.error('Run `ledger setup claude-code` to apply hook changes.');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Handle path settings
|
|
92
|
+
if (key === 'memoryDir' || key === 'claudeMdPath') {
|
|
93
|
+
config[key] = value;
|
|
94
|
+
saveConfig(config);
|
|
95
|
+
console.error(`${key}: ${value}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
console.error(`Unknown config key: ${key}`);
|
|
99
|
+
console.error(`Available: ${hookKeys.join(', ')}, memoryDir, claudeMdPath`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
export async function configList() {
|
|
103
|
+
const config = loadFullConfig();
|
|
104
|
+
const hooks = config.hooks || {};
|
|
105
|
+
console.error('Hook settings:');
|
|
106
|
+
for (const [key, desc] of Object.entries(DESCRIPTIONS)) {
|
|
107
|
+
const value = hooks[key] ?? true;
|
|
108
|
+
const state = value ? 'enabled' : 'DISABLED';
|
|
109
|
+
console.error(` ${key}: ${state} — ${desc}`);
|
|
110
|
+
}
|
|
111
|
+
console.error('\nPaths:');
|
|
112
|
+
console.error(` memoryDir: ${config.memoryDir || '(default)'}`);
|
|
113
|
+
console.error(` claudeMdPath: ${config.claudeMdPath || '(default)'}`);
|
|
114
|
+
console.error('\nConfig file: ' + CONFIG_PATH);
|
|
115
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import { searchNotes } from '../lib/notes.js';
|
|
4
|
+
import { fatal, ExitCode } from '../lib/errors.js';
|
|
5
|
+
export async function exportNote(config, query, outputPath) {
|
|
6
|
+
const results = await searchNotes(config.supabase, config.openai, query);
|
|
7
|
+
if (results.length === 0) {
|
|
8
|
+
fatal('No matching notes found.', ExitCode.NOTE_NOT_FOUND);
|
|
9
|
+
}
|
|
10
|
+
const note = results[0];
|
|
11
|
+
const upsertKey = note.metadata.upsert_key || `note-${note.id}`;
|
|
12
|
+
const filename = `${upsertKey}.md`;
|
|
13
|
+
const targetPath = outputPath
|
|
14
|
+
? resolve(outputPath, filename)
|
|
15
|
+
: resolve(process.cwd(), filename);
|
|
16
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
17
|
+
writeFileSync(targetPath, note.content + '\n', 'utf-8');
|
|
18
|
+
// No hash stored — export is untracked
|
|
19
|
+
console.log(`Exported "${upsertKey}" → ${targetPath}`);
|
|
20
|
+
console.log(targetPath);
|
|
21
|
+
}
|