@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.
Files changed (37) hide show
  1. package/README.md +120 -0
  2. package/dist/cli.js +153 -0
  3. package/dist/commands/backup.js +89 -0
  4. package/dist/commands/check.js +79 -0
  5. package/dist/commands/config.js +115 -0
  6. package/dist/commands/export.js +21 -0
  7. package/dist/commands/ingest.js +221 -0
  8. package/dist/commands/init.js +142 -0
  9. package/dist/commands/onboard.js +190 -0
  10. package/dist/commands/pull.js +75 -0
  11. package/dist/commands/push.js +21 -0
  12. package/dist/commands/restore.js +72 -0
  13. package/dist/commands/setup.js +159 -0
  14. package/dist/commands/show.js +34 -0
  15. package/dist/hooks/block-env.sh +54 -0
  16. package/dist/hooks/hooks/block-env.sh +54 -0
  17. package/dist/hooks/hooks/post-write-ledger.sh +40 -0
  18. package/dist/hooks/hooks/session-end-check.sh +23 -0
  19. package/dist/hooks/post-write-ledger.sh +40 -0
  20. package/dist/hooks/session-end-check.sh +23 -0
  21. package/dist/lib/config.js +63 -0
  22. package/dist/lib/errors.js +23 -0
  23. package/dist/lib/generators.js +115 -0
  24. package/dist/lib/hash.js +4 -0
  25. package/dist/lib/migrate.js +23 -0
  26. package/dist/lib/notes.js +105 -0
  27. package/dist/lib/prompt.js +65 -0
  28. package/dist/mcp-server.js +348 -0
  29. package/dist/migrations/000-tracking.sql +4 -0
  30. package/dist/migrations/001-schema.sql +27 -0
  31. package/dist/migrations/002-functions.sql +14 -0
  32. package/dist/migrations/003-rls.sql +5 -0
  33. package/dist/migrations/migrations/000-tracking.sql +4 -0
  34. package/dist/migrations/migrations/001-schema.sql +27 -0
  35. package/dist/migrations/migrations/002-functions.sql +14 -0
  36. package/dist/migrations/migrations/003-rls.sql +5 -0
  37. package/package.json +60 -0
@@ -0,0 +1,221 @@
1
+ import { readFileSync, unlinkSync, readdirSync, existsSync } from 'fs';
2
+ import { resolve, basename } from 'path';
3
+ import { fetchCachedNotes, searchNotes } from '../lib/notes.js';
4
+ import { contentHash } from '../lib/hash.js';
5
+ import { confirm, choose } from '../lib/prompt.js';
6
+ export async function ingest(config, options) {
7
+ const existingNotes = await fetchCachedNotes(config.supabase);
8
+ if (options.file) {
9
+ if (options.auto) {
10
+ await autoIngestFile(config, resolve(options.file), existingNotes);
11
+ }
12
+ else {
13
+ await ingestFile(config, resolve(options.file), existingNotes);
14
+ }
15
+ return;
16
+ }
17
+ // Scan memory dir for unknown files
18
+ if (!existsSync(config.memoryDir)) {
19
+ console.error('Memory directory not found.');
20
+ return;
21
+ }
22
+ const knownFiles = new Set(existingNotes
23
+ .map(n => n.metadata.local_file)
24
+ .filter(Boolean));
25
+ const localFiles = readdirSync(config.memoryDir)
26
+ .filter(f => f.endsWith('.md') && f !== 'MEMORY.md' && !knownFiles.has(f));
27
+ if (localFiles.length === 0) {
28
+ console.error('No unknown files found.');
29
+ return;
30
+ }
31
+ console.error(`Found ${localFiles.length} unknown file(s):\n`);
32
+ for (const file of localFiles) {
33
+ const filePath = resolve(config.memoryDir, file);
34
+ await ingestFile(config, filePath, existingNotes);
35
+ console.error('');
36
+ }
37
+ }
38
+ async function ingestFile(config, filePath, existingNotes) {
39
+ const filename = basename(filePath);
40
+ const content = readFileSync(filePath, 'utf-8').trim();
41
+ const hash = contentHash(content);
42
+ console.error(`--- ${filename} ---`);
43
+ // Step 1: Check for exact duplicates by hash
44
+ const exactMatch = existingNotes.find(n => n.metadata.content_hash === hash);
45
+ if (exactMatch) {
46
+ const key = exactMatch.metadata.upsert_key || `note-${exactMatch.id}`;
47
+ console.error(`This file is identical to "${key}" in Ledger.`);
48
+ console.error(`\nExisting note content:\n${exactMatch.content.slice(0, 500)}${exactMatch.content.length > 500 ? '\n...' : ''}\n`);
49
+ const skip = await confirm('Skip ingestion?');
50
+ if (skip) {
51
+ console.error(`Skipped ${filename}.`);
52
+ return;
53
+ }
54
+ }
55
+ // Step 2: Check for similar notes by embedding
56
+ if (!exactMatch) {
57
+ const similar = await searchNotes(config.supabase, config.openai, content, 0.5, 3);
58
+ if (similar.length > 0) {
59
+ const topMatch = similar[0];
60
+ const key = topMatch.metadata.upsert_key || `note-${topMatch.id}`;
61
+ console.error(`Similar note found: "${key}" (similarity: ${topMatch.similarity.toFixed(3)})`);
62
+ console.error(`\nExisting:\n${topMatch.content.slice(0, 500)}${topMatch.content.length > 500 ? '\n...' : ''}`);
63
+ console.error(`\nNew:\n${content.slice(0, 500)}${content.length > 500 ? '\n...' : ''}\n`);
64
+ const action = await choose('What would you like to do?', [
65
+ 'Merge into existing',
66
+ 'Replace existing',
67
+ 'Add as new note',
68
+ 'Skip',
69
+ ]);
70
+ switch (action) {
71
+ case 'Merge into existing': {
72
+ const merged = `${topMatch.content}\n\n---\n\n${content}`;
73
+ await updateAndHash(config, topMatch.id, merged);
74
+ console.error(`Merged into "${key}".`);
75
+ await askDeleteLocal(filePath, filename);
76
+ return;
77
+ }
78
+ case 'Replace existing': {
79
+ await updateAndHash(config, topMatch.id, content);
80
+ console.error(`Replaced "${key}".`);
81
+ await askDeleteLocal(filePath, filename);
82
+ return;
83
+ }
84
+ case 'Skip': {
85
+ console.error(`Skipped ${filename}.`);
86
+ return;
87
+ }
88
+ // 'Add as new note' falls through to create below
89
+ }
90
+ }
91
+ }
92
+ // Step 3: No match or user chose "Add as new" — create new note
93
+ const shouldIngest = exactMatch ? true : await confirm(`Add "${filename}" to Ledger?`);
94
+ if (!shouldIngest) {
95
+ console.error(`Skipped ${filename}.`);
96
+ return;
97
+ }
98
+ const noteType = await choose('Note type:', [
99
+ 'feedback',
100
+ 'user-preference',
101
+ 'architecture-decision',
102
+ 'project-status',
103
+ 'reference',
104
+ 'event',
105
+ 'error',
106
+ 'general',
107
+ ]);
108
+ const { openai } = config;
109
+ const embeddingResponse = await openai.embeddings.create({
110
+ model: 'text-embedding-3-small',
111
+ input: content,
112
+ });
113
+ const embedding = embeddingResponse.data[0].embedding;
114
+ const upsertKey = filename.replace(/\.md$/, '').replace(/_/g, '-');
115
+ const { data, error } = await config.supabase
116
+ .from('notes')
117
+ .insert({
118
+ content,
119
+ metadata: {
120
+ type: noteType,
121
+ agent: 'ledger-ingest',
122
+ upsert_key: upsertKey,
123
+ local_file: filename,
124
+ content_hash: hash,
125
+ local_cache: true,
126
+ },
127
+ embedding,
128
+ })
129
+ .select('id')
130
+ .single();
131
+ if (error) {
132
+ console.error(`Error adding note: ${error.message}`);
133
+ return;
134
+ }
135
+ console.error(`Added "${filename}" → Ledger (note ${data.id})`);
136
+ await askDeleteLocal(filePath, filename);
137
+ }
138
+ async function updateAndHash(config, noteId, content) {
139
+ const { openai, supabase } = config;
140
+ const embeddingResponse = await openai.embeddings.create({
141
+ model: 'text-embedding-3-small',
142
+ input: content,
143
+ });
144
+ const embedding = embeddingResponse.data[0].embedding;
145
+ const hash = contentHash(content);
146
+ const { data: note } = await supabase
147
+ .from('notes')
148
+ .select('metadata')
149
+ .eq('id', noteId)
150
+ .single();
151
+ const metadata = {
152
+ ...(note?.metadata || {}),
153
+ content_hash: hash,
154
+ };
155
+ await supabase
156
+ .from('notes')
157
+ .update({ content, embedding, metadata, updated_at: new Date().toISOString() })
158
+ .eq('id', noteId);
159
+ }
160
+ async function askDeleteLocal(filePath, filename) {
161
+ const shouldDelete = await confirm(`Delete local file "${filename}"?`);
162
+ if (shouldDelete) {
163
+ unlinkSync(filePath);
164
+ console.error(`Deleted ${filename}.`);
165
+ }
166
+ else {
167
+ console.error(`Kept ${filename} locally.`);
168
+ }
169
+ }
170
+ async function autoIngestFile(config, filePath, existingNotes) {
171
+ if (!existsSync(filePath))
172
+ return;
173
+ const filename = basename(filePath);
174
+ const content = readFileSync(filePath, 'utf-8').trim();
175
+ const hash = contentHash(content);
176
+ // Check for exact duplicate — skip silently if identical
177
+ const exactMatch = existingNotes.find(n => n.metadata.content_hash === hash);
178
+ if (exactMatch) {
179
+ unlinkSync(filePath);
180
+ console.error(`AUTO: ${filename} — identical to existing note, deleted local.`);
181
+ return;
182
+ }
183
+ // Infer type from filename
184
+ let noteType = 'general';
185
+ if (filename.startsWith('feedback_'))
186
+ noteType = 'feedback';
187
+ else if (filename.startsWith('user_'))
188
+ noteType = 'user-preference';
189
+ else if (filename.startsWith('project_'))
190
+ noteType = 'project-status';
191
+ else if (filename.startsWith('reference_'))
192
+ noteType = 'reference';
193
+ const embeddingResponse = await config.openai.embeddings.create({
194
+ model: 'text-embedding-3-small',
195
+ input: content,
196
+ });
197
+ const embedding = embeddingResponse.data[0].embedding;
198
+ const upsertKey = filename.replace(/\.md$/, '').replace(/_/g, '-');
199
+ const { data, error } = await config.supabase
200
+ .from('notes')
201
+ .insert({
202
+ content,
203
+ metadata: {
204
+ type: noteType,
205
+ agent: 'ledger-auto-ingest',
206
+ upsert_key: upsertKey,
207
+ local_file: filename,
208
+ content_hash: hash,
209
+ local_cache: true,
210
+ },
211
+ embedding,
212
+ })
213
+ .select('id')
214
+ .single();
215
+ if (error) {
216
+ console.error(`AUTO: Error ingesting ${filename}: ${error.message}`);
217
+ return;
218
+ }
219
+ unlinkSync(filePath);
220
+ console.error(`AUTO: ${filename} → Ledger (note ${data.id}), deleted local.`);
221
+ }
@@ -0,0 +1,142 @@
1
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { createClient } from '@supabase/supabase-js';
4
+ import OpenAI from 'openai';
5
+ import { ask, askMasked, confirm } from '../lib/prompt.js';
6
+ import { getLedgerDir, loadConfigFile, getDefaultConfig } from '../lib/config.js';
7
+ import { getMigrationFiles, readMigration } from '../lib/migrate.js';
8
+ import { enableBackupCron } from './backup.js';
9
+ export async function init() {
10
+ const ledgerDir = getLedgerDir();
11
+ const envPath = resolve(ledgerDir, '.env');
12
+ const configPath = resolve(ledgerDir, 'config.json');
13
+ console.error('Welcome to Ledger.\n');
14
+ mkdirSync(ledgerDir, { recursive: true });
15
+ let supabaseUrl = '';
16
+ let supabaseKey = '';
17
+ let openaiKey = '';
18
+ // Step 1: Check existing credentials
19
+ if (existsSync(envPath)) {
20
+ const overwrite = await confirm('Existing credentials found. Overwrite?');
21
+ if (!overwrite) {
22
+ console.error('Keeping existing credentials.\n');
23
+ const envContent = readFileSync(envPath, 'utf-8');
24
+ for (const line of envContent.split('\n')) {
25
+ const eqIndex = line.indexOf('=');
26
+ if (eqIndex === -1)
27
+ continue;
28
+ const key = line.slice(0, eqIndex);
29
+ const value = line.slice(eqIndex + 1);
30
+ if (key === 'SUPABASE_URL')
31
+ supabaseUrl = value;
32
+ if (key === 'SUPABASE_SERVICE_ROLE_KEY')
33
+ supabaseKey = value;
34
+ if (key === 'OPENAI_API_KEY')
35
+ openaiKey = value;
36
+ }
37
+ }
38
+ }
39
+ // Step 2: Get credentials
40
+ if (!supabaseUrl) {
41
+ const hasProject = await confirm('Do you have a Supabase project?');
42
+ if (!hasProject) {
43
+ console.error(`
44
+ To create a Supabase project:
45
+ 1. Go to https://supabase.com and create a free account
46
+ 2. Create a new project (any name, any region)
47
+ 3. Enable pgvector: Database > Extensions > search "vector" > Enable
48
+ 4. Go to Settings > API and copy:
49
+ - Project URL
50
+ - service_role key (under "Project API keys")
51
+ 5. Get an OpenAI API key from https://platform.openai.com/api-keys
52
+ `);
53
+ await ask('Press Enter when ready...');
54
+ }
55
+ supabaseUrl = await ask('Supabase URL: ');
56
+ supabaseKey = await askMasked('Service Role Key: ');
57
+ openaiKey = await askMasked('OpenAI API Key (required for embeddings, even with Claude): ');
58
+ const envContent = [
59
+ `SUPABASE_URL=${supabaseUrl}`,
60
+ `SUPABASE_SERVICE_ROLE_KEY=${supabaseKey}`,
61
+ `OPENAI_API_KEY=${openaiKey}`,
62
+ '',
63
+ ].join('\n');
64
+ writeFileSync(envPath, envContent, { mode: 0o600 });
65
+ console.error('Credentials saved to ~/.ledger/.env\n');
66
+ }
67
+ // Step 3: Write/merge config.json
68
+ const existing = loadConfigFile();
69
+ const defaults = getDefaultConfig();
70
+ const merged = {
71
+ ...defaults,
72
+ ...existing,
73
+ hooks: { ...defaults.hooks, ...existing.hooks },
74
+ };
75
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n');
76
+ console.error('Config saved to ~/.ledger/config.json\n');
77
+ // Step 4: Verify Supabase connection
78
+ console.error('Connecting to Supabase...');
79
+ const supabase = createClient(supabaseUrl, supabaseKey);
80
+ const { error: connError } = await supabase
81
+ .from('schema_migrations')
82
+ .select('version')
83
+ .limit(1);
84
+ const isNew = connError?.code === '42P01'; // relation does not exist
85
+ if (connError && !isNew) {
86
+ console.error(`Connection error: ${connError.message}`);
87
+ console.error('Check your Supabase URL and service role key.');
88
+ process.exit(1);
89
+ }
90
+ console.error('Connected.\n');
91
+ // Step 5: Validate OpenAI key
92
+ console.error('Validating OpenAI key...');
93
+ try {
94
+ const openai = new OpenAI({ apiKey: openaiKey });
95
+ await openai.embeddings.create({ model: 'text-embedding-3-small', input: 'test' });
96
+ console.error('OpenAI key valid.\n');
97
+ }
98
+ catch (e) {
99
+ console.error(`OpenAI key invalid: ${e.message}`);
100
+ console.error('Check your OpenAI API key.');
101
+ process.exit(1);
102
+ }
103
+ // Step 6: Run migrations or confirm existing
104
+ if (isNew) {
105
+ console.error('New database detected. Setting up schema...\n');
106
+ const files = getMigrationFiles();
107
+ const allSql = files.map(f => {
108
+ const sql = readMigration(f);
109
+ return `-- ${f}\n${sql}`;
110
+ }).join('\n\n');
111
+ console.error('Run the following SQL in Supabase Dashboard > SQL Editor:\n');
112
+ console.error('='.repeat(60));
113
+ console.error(allSql);
114
+ console.error('='.repeat(60));
115
+ console.error('');
116
+ await ask('Press Enter after running the SQL...');
117
+ // Verify
118
+ const { error: verifyError } = await supabase
119
+ .from('notes')
120
+ .select('id')
121
+ .limit(1);
122
+ if (verifyError) {
123
+ console.error('Notes table not found. Make sure you ran all the SQL above.');
124
+ process.exit(1);
125
+ }
126
+ console.error('Schema verified.\n');
127
+ }
128
+ else {
129
+ const { count } = await supabase
130
+ .from('notes')
131
+ .select('*', { count: 'exact', head: true });
132
+ console.error(`Found existing Ledger with ${count ?? 0} notes.\n`);
133
+ }
134
+ // Step 7: Offer daily backup
135
+ const wantBackup = await confirm('Enable daily local backup? (Saves all notes to ~/.ledger/backups/ at 1am)');
136
+ if (wantBackup) {
137
+ enableBackupCron();
138
+ }
139
+ console.error('\nInit complete.');
140
+ console.error('Run `ledger setup <platform>` to connect an agent.');
141
+ console.error('Platforms: claude-code, openclaw, chatgpt');
142
+ }
@@ -0,0 +1,190 @@
1
+ import { getLedgerDir } from '../lib/config.js';
2
+ import { fetchCachedNotes } from '../lib/notes.js';
3
+ import { contentHash } from '../lib/hash.js';
4
+ import { ask, confirm, choose } from '../lib/prompt.js';
5
+ import { existsSync } from 'fs';
6
+ import { resolve } from 'path';
7
+ // --- Communication Presets ---
8
+ const COMM_PRESETS = {
9
+ 'Direct': `- Concise by default, thorough only when the content demands it
10
+ - Structured outputs: headers, bullets, tables over prose
11
+ - No sycophancy: no "Great question!", no filler. Agreement means actual agreement.
12
+ - No emojis unless explicitly asked
13
+ - Scope control: do what was asked, don't gold-plate
14
+ - Don't narrate work while doing it. Do the thing, then report.
15
+ - Pushback welcome: disagree when something is wrong, bring reasoning`,
16
+ 'Educational': `- Explain concepts as you go, provide context for decisions
17
+ - Step-by-step walkthroughs for complex tasks
18
+ - Use analogies and plain language for unfamiliar topics
19
+ - Structured outputs with headers and bullets
20
+ - Be thorough — understanding matters more than speed
21
+ - No sycophancy: keep it honest and grounded`,
22
+ 'Collaborative': `- Ask before acting on anything non-trivial
23
+ - Present options with trade-offs, recommend but let user decide
24
+ - Discuss architecture and design before implementation
25
+ - Check in at milestones, don't go heads-down for too long
26
+ - Structured outputs with clear decision points
27
+ - No sycophancy: honest assessment over agreeableness`,
28
+ };
29
+ // --- Default Rules (always included) ---
30
+ const DEFAULT_SECURITY_RULES = `- Never read .env files or any files containing secrets/credentials
31
+ - Never read SSH keys, certificates (.pem, .key, .p12), AWS credentials, or auth tokens
32
+ - Check file existence with 'test -f' or 'wc -l', never by reading content
33
+ - Never expose API keys, tokens, or passwords in any output`;
34
+ const DEFAULT_KNOWLEDGE_RULES = `- Ledger is the source of truth for all knowledge
35
+ - Local files are cache — update Ledger first, then local
36
+ - Use ledger CLI for syncing between Ledger and local files`;
37
+ // --- Onboard ---
38
+ export async function onboard(config) {
39
+ const envPath = resolve(getLedgerDir(), '.env');
40
+ if (!existsSync(envPath)) {
41
+ console.error('Ledger not initialized. Run `ledger init` first.');
42
+ process.exit(1);
43
+ }
44
+ // Check if persona already exists
45
+ const existing = await fetchCachedNotes(config.supabase);
46
+ const hasProfile = existing.some(n => n.metadata.type === 'user-preference');
47
+ const hasFeedback = existing.some(n => n.metadata.type === 'feedback');
48
+ if (hasProfile || hasFeedback) {
49
+ const proceed = await confirm('Persona notes already exist in Ledger. Run onboarding again? (will add, not replace)');
50
+ if (!proceed) {
51
+ console.error('Cancelled.');
52
+ return;
53
+ }
54
+ }
55
+ console.error('\nLet\'s set up your AI persona.\n');
56
+ // 1. Name
57
+ const name = await ask('What\'s your name? ');
58
+ // 2. Role
59
+ const role = await ask('What do you do? (e.g. Software Engineer, Student, Designer) ');
60
+ // 3. Communication style
61
+ const commStyle = await choose('How should the AI communicate with you?', [
62
+ 'Direct — concise, no filler, structured',
63
+ 'Educational — explain as you go, step by step',
64
+ 'Collaborative — ask before acting, discuss trade-offs',
65
+ 'Custom — define your own rules later',
66
+ ]);
67
+ // 4. Technical level
68
+ const techLevel = await choose('Technical skill level?', [
69
+ 'Beginner — new to coding, explain everything',
70
+ 'Intermediate — comfortable coding, explain advanced concepts',
71
+ 'Senior — experienced, skip basics, focus on architecture',
72
+ ]);
73
+ // 5. Languages/frameworks
74
+ const languages = await ask('Languages and frameworks you use? (comma-separated, or "skip") ');
75
+ // 6. Learning goals (optional)
76
+ const wantGoals = await confirm('Want to add learning goals?');
77
+ let goals = '';
78
+ if (wantGoals) {
79
+ goals = await ask('What are you learning or working toward? ');
80
+ }
81
+ // --- Create notes ---
82
+ console.error('\nCreating persona in Ledger...\n');
83
+ // User profile
84
+ const profileContent = [
85
+ `## Role`,
86
+ role,
87
+ '',
88
+ `## Technical Level`,
89
+ techLevel.split(' — ')[0],
90
+ '',
91
+ ];
92
+ if (languages && languages.toLowerCase() !== 'skip') {
93
+ profileContent.push('## Technical Skills', languages, '');
94
+ }
95
+ if (goals) {
96
+ profileContent.push('## Learning Goals', goals, '');
97
+ }
98
+ await createNote(config, {
99
+ content: profileContent.join('\n'),
100
+ type: 'user-preference',
101
+ upsertKey: 'user-profile',
102
+ localFile: 'user_profile.md',
103
+ label: `${name}'s profile`,
104
+ });
105
+ // Communication style
106
+ let commContent;
107
+ if (commStyle.startsWith('Custom')) {
108
+ commContent = '- Define your communication preferences here\n- Edit this note to customize';
109
+ }
110
+ else {
111
+ const presetKey = commStyle.split(' — ')[0];
112
+ commContent = COMM_PRESETS[presetKey] || COMM_PRESETS['Direct'];
113
+ }
114
+ await createNote(config, {
115
+ content: commContent,
116
+ type: 'feedback',
117
+ upsertKey: 'feedback-communication-style',
118
+ localFile: 'feedback_communication_style.md',
119
+ label: 'communication style',
120
+ });
121
+ // Technical level as working style
122
+ const levelDescriptions = {
123
+ 'Beginner': '- Explain all concepts in plain language with analogies\n- Step-by-step walkthroughs\n- Don\'t assume familiarity with tools or syntax',
124
+ 'Intermediate': '- Explain advanced concepts, skip basics\n- Provide context for architectural decisions\n- Assume familiarity with common tools and patterns',
125
+ 'Senior': '- Skip explanations unless asked\n- Focus on architecture, trade-offs, edge cases\n- Assume deep familiarity with tools and patterns',
126
+ };
127
+ const levelKey = techLevel.split(' — ')[0];
128
+ await createNote(config, {
129
+ content: levelDescriptions[levelKey] || levelDescriptions['Intermediate'],
130
+ type: 'user-preference',
131
+ upsertKey: 'user-working-style',
132
+ localFile: 'user_working_style.md',
133
+ label: 'working style',
134
+ });
135
+ // Default security rules (always)
136
+ await createNote(config, {
137
+ content: DEFAULT_SECURITY_RULES,
138
+ type: 'feedback',
139
+ upsertKey: 'feedback-no-read-env',
140
+ localFile: 'feedback_no_read_env.md',
141
+ label: 'security rules',
142
+ });
143
+ // Default knowledge rules (always)
144
+ await createNote(config, {
145
+ content: DEFAULT_KNOWLEDGE_RULES,
146
+ type: 'feedback',
147
+ upsertKey: 'feedback-knowledge-system',
148
+ localFile: 'feedback_knowledge_system.md',
149
+ label: 'knowledge system rules',
150
+ });
151
+ console.error('\nPersona created. Run `ledger pull` to sync locally, or `ledger setup <platform>` if not done yet.');
152
+ }
153
+ async function createNote(config, input) {
154
+ const { content, type, upsertKey, localFile, label } = input;
155
+ // Check for existing note with same upsert_key
156
+ const { data: existing } = await config.supabase
157
+ .from('notes')
158
+ .select('id')
159
+ .eq('metadata->>upsert_key', upsertKey)
160
+ .limit(1)
161
+ .single();
162
+ if (existing) {
163
+ console.error(` skip "${label}" (already exists)`);
164
+ return;
165
+ }
166
+ const embeddingResponse = await config.openai.embeddings.create({
167
+ model: 'text-embedding-3-small',
168
+ input: content,
169
+ });
170
+ const embedding = embeddingResponse.data[0].embedding;
171
+ const { error } = await config.supabase
172
+ .from('notes')
173
+ .insert({
174
+ content,
175
+ metadata: {
176
+ type,
177
+ agent: 'ledger-onboard',
178
+ upsert_key: upsertKey,
179
+ local_file: localFile,
180
+ local_cache: true,
181
+ content_hash: contentHash(content),
182
+ },
183
+ embedding,
184
+ });
185
+ if (error) {
186
+ console.error(` error creating "${label}": ${error.message}`);
187
+ return;
188
+ }
189
+ console.error(` created "${label}"`);
190
+ }
@@ -0,0 +1,75 @@
1
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { fetchCachedNotes, updateNoteHash } from '../lib/notes.js';
4
+ import { contentHash } from '../lib/hash.js';
5
+ import { generateClaudeMd, generateMemoryMd } from '../lib/generators.js';
6
+ export async function pull(config, options) {
7
+ const { quiet, force } = options;
8
+ const notes = await fetchCachedNotes(config.supabase);
9
+ if (notes.length === 0) {
10
+ if (!quiet)
11
+ console.error('No cached notes found in Ledger.');
12
+ return;
13
+ }
14
+ mkdirSync(config.memoryDir, { recursive: true });
15
+ const writtenFiles = [];
16
+ const conflicts = [];
17
+ for (const note of notes) {
18
+ const localFile = note.metadata.local_file;
19
+ if (!localFile)
20
+ continue;
21
+ const filePath = resolve(config.memoryDir, localFile);
22
+ const ledgerContent = note.content;
23
+ const ledgerHash = contentHash(ledgerContent);
24
+ const storedHash = note.metadata.content_hash;
25
+ if (!force && existsSync(filePath)) {
26
+ const localRaw = readFileSync(filePath, 'utf-8').trim();
27
+ const localHash = contentHash(localRaw);
28
+ // If local content differs from what we last wrote (stored hash), it's been modified
29
+ if (storedHash && localHash !== storedHash) {
30
+ // If Ledger also changed, it's a conflict
31
+ if (ledgerHash !== storedHash) {
32
+ conflicts.push(localFile);
33
+ console.log(`CONFLICT:${localFile}`);
34
+ continue;
35
+ }
36
+ // Only local changed — don't overwrite
37
+ conflicts.push(localFile);
38
+ console.log(`CONFLICT:${localFile}`);
39
+ continue;
40
+ }
41
+ }
42
+ writeFileSync(filePath, `${ledgerContent}\n`, 'utf-8');
43
+ writtenFiles.push(localFile);
44
+ // Store the hash of what we wrote so check can detect local modifications
45
+ if (!storedHash || storedHash !== ledgerHash) {
46
+ await updateNoteHash(config.supabase, note.id, ledgerHash);
47
+ }
48
+ if (!quiet)
49
+ console.error(` wrote ${localFile}`);
50
+ }
51
+ writeGeneratedFiles(config, notes, writtenFiles, conflicts, force, quiet);
52
+ if (!quiet) {
53
+ console.error(`\nPull complete: ${writtenFiles.length} written, ${conflicts.length} conflicts`);
54
+ }
55
+ }
56
+ function writeGeneratedFiles(config, notes, writtenFiles, conflicts, force, quiet) {
57
+ const allLocalFiles = [...writtenFiles, ...conflicts];
58
+ const memoryPath = resolve(config.memoryDir, 'MEMORY.md');
59
+ writeFileSync(memoryPath, generateMemoryMd(allLocalFiles), 'utf-8');
60
+ if (!quiet)
61
+ console.error(' wrote MEMORY.md');
62
+ const feedbackNotes = notes.filter(n => n.metadata.type === 'feedback');
63
+ const newClaudeMd = generateClaudeMd(feedbackNotes);
64
+ // Protect user-authored CLAUDE.md — only overwrite if it was generated by Ledger
65
+ if (existsSync(config.claudeMdPath)) {
66
+ const existing = readFileSync(config.claudeMdPath, 'utf-8');
67
+ if (!existing.startsWith('# Global Rules') && !force) {
68
+ console.log('CONFLICT:CLAUDE.md (exists with non-Ledger content, use --force to overwrite)');
69
+ return;
70
+ }
71
+ }
72
+ writeFileSync(config.claudeMdPath, newClaudeMd, 'utf-8');
73
+ if (!quiet)
74
+ console.error(' wrote ~/CLAUDE.md');
75
+ }
@@ -0,0 +1,21 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { resolve, basename } from 'path';
3
+ import { findNoteByFile, updateNoteContent, updateNoteHash } from '../lib/notes.js';
4
+ import { contentHash } from '../lib/hash.js';
5
+ import { fatal, ExitCode } from '../lib/errors.js';
6
+ export async function push(config, filePath) {
7
+ const absPath = resolve(filePath);
8
+ if (!existsSync(absPath)) {
9
+ fatal(`File not found: ${absPath}`, ExitCode.FILE_NOT_FOUND);
10
+ }
11
+ const filename = basename(absPath);
12
+ const content = readFileSync(absPath, 'utf-8').trim();
13
+ const existing = await findNoteByFile(config.supabase, filename);
14
+ if (!existing) {
15
+ fatal(`No Ledger note matching "${filename}" found. Add it via MCP first.`, ExitCode.NOTE_NOT_FOUND);
16
+ }
17
+ await updateNoteContent(config.supabase, config.openai, existing.id, content);
18
+ const hash = contentHash(content);
19
+ await updateNoteHash(config.supabase, existing.id, hash);
20
+ console.log(`Pushed ${filename} → Ledger (note ${existing.id})`);
21
+ }