@aperdomoll90/ledger-ai 1.1.1 → 1.1.2

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 CHANGED
@@ -15,24 +15,26 @@ Requires Node.js 20+.
15
15
  ## Quick Start
16
16
 
17
17
  ```bash
18
- # 1. Set up credentials and database
18
+ # One command does everything:
19
19
  ledger init
20
+ ```
20
21
 
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)
22
+ The init wizard walks you through:
23
+ 1. **Credentials** Supabase + OpenAI keys
24
+ 2. **Database** connect and set up schema
25
+ 3. **Device** name this machine (optional)
26
+ 4. **Persona** — profile, communication style, rules
27
+ 5. **Platforms** — install Claude Code, OpenClaw, or ChatGPT
28
+ 6. **Sync** — pull everything down
29
+ 7. **Migrate** — detect stray local files
25
30
 
26
- # 3. Create your persona
27
- ledger onboard
28
- ```
31
+ Smart step-skipping re-run anytime, it only does what's needed.
29
32
 
30
- Second device? Same Ledger, same persona:
33
+ ### Second device? Same Ledger, same persona:
31
34
 
32
35
  ```bash
33
36
  npm install -g @aperdomoll90/ledger-ai
34
- ledger init # connect to existing Supabase project
35
- ledger setup claude # pull persona, install hooks
37
+ ledger init # connect to existing Supabase, pull persona, set up platform
36
38
  ```
37
39
 
38
40
  ## What It Does
@@ -47,19 +49,53 @@ ledger setup claude # pull persona, install hooks
47
49
 
48
50
  | Command | Description |
49
51
  |---|---|
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) |
52
+ | `ledger init` | Guided setup wizard (credentials, persona, platforms, sync) |
53
+ | `ledger setup <platform>` | Configure an agent (claude-code, openclaw, chatgpt) |
54
+ | `ledger onboard` | Create your persona (interactive questionnaire) |
55
+ | `ledger sync` | Bidirectional sync between Ledger and local cache |
53
56
  | `ledger pull` | Download notes from Ledger to local cache |
54
57
  | `ledger push <file>` | Upload a local file to Ledger |
55
- | `ledger check` | Compare local files vs Ledger |
58
+ | `ledger check` | Compare local files vs Ledger (dry-run sync) |
56
59
  | `ledger show <query>` | Search by meaning, open matching note |
57
60
  | `ledger export <query>` | Download a note to any path (untracked) |
58
61
  | `ledger ingest [file]` | Add files to Ledger with duplicate detection |
62
+ | `ledger migrate` | Migrate local files to Ledger (backup, compare, merge) |
63
+ | `ledger add` | Add a new note (interactive metadata prompts) |
64
+ | `ledger update <id>` | Update a note by ID |
65
+ | `ledger delete <id>` | Delete a note by ID |
66
+ | `ledger list` | List recent notes |
67
+ | `ledger tag <id>` | Update metadata on a note |
59
68
  | `ledger backup` | Backup all notes to ~/.ledger/backups/ |
60
69
  | `ledger restore <file>` | Restore from backup |
61
70
  | `ledger config list` | View settings |
62
- | `ledger config set <key> <value>` | Change settings |
71
+
72
+ ## Note Metadata
73
+
74
+ Every note has structured metadata for organization and discovery:
75
+
76
+ | Field | Purpose | Example |
77
+ |---|---|---|
78
+ | `type` | Categorization | `feedback`, `user-preference`, `architecture-decision`, `reference` |
79
+ | `upsert_key` | Unique identifier, dedup | `feedback-communication-style`, `ledger-spec-init` |
80
+ | `description` | What the note IS and what it's FOR | `"How to communicate with Adrian"` |
81
+ | `status` | Lifecycle stage | `idea`, `planning`, `active`, `done` |
82
+ | `project` | Which project | `ledger`, `ai-studio` |
83
+ | `delivery` | Sync tier | `persona` (everywhere), `project` (per-repo), `knowledge` (search only) |
84
+
85
+ ### Interactive prompting
86
+
87
+ When creating notes, Ledger prompts for missing metadata by default — both via MCP (agents) and CLI. This helps keep notes organized without requiring users to memorize the schema.
88
+
89
+ Skip prompts with `--force` (CLI) or `interactive_skip: true` (MCP). Disable globally: `ledger config set naming.interactive false`.
90
+
91
+ ### Naming enforcement (opt-in)
92
+
93
+ Enable strict naming validation: `ledger config set naming.enforce true`
94
+
95
+ Keys follow the pattern `{prefix}-{topic}` or `{project}-{prefix}-{topic}`:
96
+ - `feedback-communication-style`
97
+ - `ledger-spec-init`
98
+ - `user-profile`
63
99
 
64
100
  ## Stack
65
101
 
@@ -77,11 +113,11 @@ Your devices / agents
77
113
  v
78
114
  ledger CLI ←→ Supabase (Postgres + pgvector)
79
115
  |
80
- ├── pull: download noteslocal cache files + CLAUDE.md
81
- ├── push: upload local changes → Ledger (re-embeds)
82
- ├── check: compare hashes, detect drift
116
+ ├── init: guided wizard (credentials persona platforms sync)
117
+ ├── sync: bidirectional, hash-based conflict detection
83
118
  ├── show: semantic search → open in editor
84
- └── MCP server: Claude Code talks to Ledger natively
119
+ ├── add: interactive metadata prompts duplicate guard → save
120
+ └── MCP server: Claude Code talks to Ledger natively (6 tools)
85
121
  ```
86
122
 
87
123
  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.
@@ -94,6 +130,15 @@ Notes are stored with content + metadata + vector embeddings. Search finds relev
94
130
  | OpenClaw | CLI | Bidirectional via `ledger` commands |
95
131
  | ChatGPT | None | Static snapshot, re-run to update |
96
132
 
133
+ Platform management is built into the wizard — install, reinstall, or uninstall from `ledger init`.
134
+
135
+ ## Development
136
+
137
+ ```bash
138
+ npm run ship # typecheck → test → commit → push
139
+ npm run release # version bump → build → test → publish to npm
140
+ ```
141
+
97
142
  ## Requirements
98
143
 
99
144
  - Node.js 20+
@@ -102,9 +147,9 @@ Notes are stored with content + metadata + vector embeddings. Search finds relev
102
147
 
103
148
  ## Known Limitations
104
149
 
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.
150
+ - **Embeddings require OpenAI** — currently uses `text-embedding-3-small` only. Multi-provider support planned.
106
151
  - **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.
152
+ - **English-optimized** — semantic search works best with English content.
108
153
 
109
154
  ## Roadmap
110
155
 
@@ -113,6 +158,7 @@ Notes are stored with content + metadata + vector embeddings. Search finds relev
113
158
  - Soft delete (trash)
114
159
  - Multi-format ingest (PDF, Excel, images, audio)
115
160
  - Web dashboard
161
+ - Skills system (context-aware convention enforcement)
116
162
  - VS Code extension
117
163
 
118
164
  ## License
package/dist/cli.js CHANGED
@@ -192,11 +192,13 @@ program
192
192
  .command('add')
193
193
  .description('Add a new note to Ledger (with duplicate detection)')
194
194
  .requiredOption('-c, --content <content>', 'note content (or use stdin)')
195
- .requiredOption('-t, --type <type>', 'note type (feedback, reference, event, etc.)')
195
+ .option('-t, --type <type>', 'note type (feedback, reference, event, etc.)')
196
196
  .option('-a, --agent <agent>', 'agent name', 'cli')
197
197
  .option('-p, --project <project>', 'project name')
198
198
  .option('-k, --upsert-key <key>', 'upsert key for dedup')
199
- .option('-f, --force', 'skip duplicate check')
199
+ .option('-d, --description <text>', 'one-line description of the note')
200
+ .option('-s, --status <status>', 'note status (idea, planning, active, done)')
201
+ .option('-f, --force', 'skip duplicate check and interactive prompts')
200
202
  .action(async (options) => {
201
203
  const config = loadConfig();
202
204
  await add(config, options.content, {
@@ -204,6 +206,8 @@ program
204
206
  agent: options.agent,
205
207
  project: options.project,
206
208
  upsertKey: options.upsertKey,
209
+ description: options.description,
210
+ status: options.status,
207
211
  force: options.force ?? false,
208
212
  });
209
213
  });
@@ -1,17 +1,71 @@
1
- import { opAddNote } from '../lib/notes.js';
2
- import { confirm } from '../lib/prompt.js';
1
+ import { loadConfigFile } from '../lib/config.js';
2
+ import { opAddNote, NOTE_TYPES, NOTE_STATUSES } from '../lib/notes.js';
3
+ import { ask, confirm, choose } from '../lib/prompt.js';
3
4
  export async function add(config, content, options) {
5
+ const configFile = loadConfigFile();
6
+ const interactive = configFile.naming?.interactive !== false;
7
+ let type = options.type || '';
4
8
  const metadata = {};
5
9
  if (options.project)
6
10
  metadata.project = options.project;
7
11
  if (options.upsertKey)
8
12
  metadata.upsert_key = options.upsertKey;
9
- const result = await opAddNote({ supabase: config.supabase, openai: config.openai }, content, options.type, options.agent || 'cli', metadata, options.force ?? false);
13
+ if (options.description)
14
+ metadata.description = options.description;
15
+ if (options.status)
16
+ metadata.status = options.status;
17
+ // Interactive prompting for missing fields (CLI only)
18
+ if (interactive && !options.force) {
19
+ // Type
20
+ if (!type) {
21
+ const typeChoice = await choose('What type of note is this?', [
22
+ ...NOTE_TYPES,
23
+ 'skip — use default (general)',
24
+ ]);
25
+ type = typeChoice.startsWith('skip') ? 'general' : typeChoice;
26
+ }
27
+ // Description
28
+ if (!metadata.description) {
29
+ const desc = await ask('One-line description (what is this note for?): ');
30
+ if (desc)
31
+ metadata.description = desc;
32
+ }
33
+ // upsert_key
34
+ if (!metadata.upsert_key) {
35
+ const key = await ask('Unique key for this note (lowercase-hyphenated, or Enter to auto-generate): ');
36
+ if (key)
37
+ metadata.upsert_key = key;
38
+ }
39
+ // Project
40
+ if (!metadata.project) {
41
+ const proj = await ask('Project name (or Enter to skip): ');
42
+ if (proj)
43
+ metadata.project = proj;
44
+ }
45
+ // Status (only for project-scoped types)
46
+ const projectTypes = ['architecture-decision', 'project-status', 'event', 'error'];
47
+ if (projectTypes.includes(type) && !metadata.status) {
48
+ const statusChoice = await choose('What stage is this?', [
49
+ ...NOTE_STATUSES,
50
+ 'skip — no status',
51
+ ]);
52
+ if (!statusChoice.startsWith('skip')) {
53
+ metadata.status = statusChoice;
54
+ }
55
+ }
56
+ }
57
+ // Default type if still empty
58
+ if (!type)
59
+ type = 'general';
60
+ // Mark as having gone through interactive (or skipped it)
61
+ // so opAddNote doesn't re-prompt via MCP confirm flow
62
+ metadata.interactive_skip = true;
63
+ const result = await opAddNote({ supabase: config.supabase, openai: config.openai }, content, type, options.agent || 'cli', metadata, options.force ?? false);
10
64
  if (result.status === 'confirm') {
11
65
  console.error(result.message);
12
66
  const proceed = await confirm('\nCreate new note anyway?');
13
67
  if (proceed) {
14
- const forced = await opAddNote({ supabase: config.supabase, openai: config.openai }, content, options.type, options.agent || 'cli', metadata, true);
68
+ const forced = await opAddNote({ supabase: config.supabase, openai: config.openai }, content, type, options.agent || 'cli', { ...metadata, interactive_skip: true }, true);
15
69
  console.error(forced.message);
16
70
  }
17
71
  else {
@@ -34,22 +34,25 @@ const DEFAULT_SECURITY_RULES = `- Never read .env files or any files containing
34
34
  const DEFAULT_KNOWLEDGE_RULES = `- Ledger is the source of truth for all knowledge
35
35
  - Local files are cache — update Ledger first, then local
36
36
  - Use ledger CLI for syncing between Ledger and local files`;
37
- // --- Onboard ---
38
- export async function onboard(config) {
37
+ export async function onboard(config, options = {}) {
39
38
  const envPath = resolve(getLedgerDir(), '.env');
40
39
  if (!existsSync(envPath)) {
41
40
  console.error('Ledger not initialized. Run `ledger init` first.');
42
41
  process.exit(1);
43
42
  }
44
- // Check if persona already exists
45
- const existing = await fetchPersonaNotes(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;
43
+ // Check if persona already exists (skip when called from wizard, which handles this itself)
44
+ if (!options.skipExistingCheck) {
45
+ const existing = await fetchPersonaNotes(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
+ console.error('You already have persona notes in Ledger (profile, communication style, etc.).');
50
+ console.error('Running onboarding again will create new notes for any missing fields — existing ones are kept as-is.\n');
51
+ const proceed = await confirm('Continue?');
52
+ if (!proceed) {
53
+ console.error('Cancelled.');
54
+ return;
55
+ }
53
56
  }
54
57
  }
55
58
  console.error('\nLet\'s set up your AI persona.\n');
@@ -91,12 +91,12 @@ export async function wizard() {
91
91
  console.error(`Step 4: Persona: found\n`);
92
92
  const update = await confirm(' Update persona?');
93
93
  if (update) {
94
- await onboard(config);
94
+ await onboard(config, { skipExistingCheck: true });
95
95
  }
96
96
  }
97
97
  else {
98
98
  console.error('Step 4: Build persona\n');
99
- await runSkippable('Persona', () => onboard(config));
99
+ await runSkippable('Persona', () => onboard(config, { skipExistingCheck: true }));
100
100
  }
101
101
  // Step 5: Platforms
102
102
  console.error('Step 5: Platform setup\n');
package/dist/lib/notes.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { fatal, ExitCode } from './errors.js';
3
3
  import { contentHash } from './hash.js';
4
+ import { loadConfigFile } from './config.js';
4
5
  const DELIVERY_BY_TYPE = {
5
6
  'user-preference': 'persona',
6
7
  'feedback': 'persona',
@@ -158,6 +159,116 @@ function formatNotePreview(id, meta, content, maxLen = 300) {
158
159
  const descLine = desc ? `\nDescription: ${desc}` : '';
159
160
  return `"${label}" (id: ${id}) | type: ${noteType || '-'} | project: ${project || '-'}${descLine}\n ${preview}${truncated}`;
160
161
  }
162
+ // --- Naming Conventions ---
163
+ /** Valid type prefixes for upsert_key naming. */
164
+ const TYPE_PREFIXES = {
165
+ 'feedback': ['feedback'],
166
+ 'user-preference': ['user'],
167
+ 'architecture-decision': ['spec', 'architecture'],
168
+ 'project-status': ['project-status'],
169
+ 'reference': ['reference'],
170
+ 'event': ['devlog', 'event'],
171
+ 'error': ['errorlog', 'error'],
172
+ 'general': ['general'],
173
+ };
174
+ /**
175
+ * Validate upsert_key format: {prefix}-{topic} or {project}-{prefix}-{topic}
176
+ * Returns null if valid, error message if invalid.
177
+ */
178
+ export function validateNaming(upsertKey, type, description) {
179
+ if (!upsertKey) {
180
+ return 'upsert_key is required when naming enforcement is enabled.';
181
+ }
182
+ if (!description) {
183
+ return 'description is required when naming enforcement is enabled. Add a one-line description of what this note IS and what it\'s FOR.';
184
+ }
185
+ // Check format: lowercase, hyphens, no underscores or special chars
186
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(upsertKey)) {
187
+ return `Invalid upsert_key format "${upsertKey}". Use lowercase-hyphenated (e.g., "feedback-communication-style").`;
188
+ }
189
+ // Check that the key contains a valid prefix for this type
190
+ const validPrefixes = TYPE_PREFIXES[type];
191
+ if (validPrefixes) {
192
+ const parts = upsertKey.split('-');
193
+ // Match prefix at start, or after a project name segment
194
+ const hasValidPrefix = validPrefixes.some(prefix => {
195
+ const prefixParts = prefix.split('-');
196
+ // Direct match: prefix-topic
197
+ if (parts.slice(0, prefixParts.length).join('-') === prefix)
198
+ return true;
199
+ // Project-scoped: project-prefix or project-prefix-topic
200
+ if (parts.length >= prefixParts.length + 1) {
201
+ const afterProject = parts.slice(1, 1 + prefixParts.length).join('-');
202
+ if (afterProject === prefix)
203
+ return true;
204
+ }
205
+ return false;
206
+ });
207
+ if (!hasValidPrefix) {
208
+ return `upsert_key "${upsertKey}" doesn't match type "${type}". Expected prefix: ${validPrefixes.join(' or ')}. Examples: "${validPrefixes[0]}-my-topic" or "myproject-${validPrefixes[0]}-my-topic".`;
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ /** Derive local_file from upsert_key: feedback-style → feedback_style.md */
214
+ export function deriveLocalFile(upsertKey) {
215
+ return upsertKey.replace(/-/g, '_') + '.md';
216
+ }
217
+ /** Check if naming enforcement is enabled in config. */
218
+ function isNamingEnforced() {
219
+ const config = loadConfigFile();
220
+ return config.naming?.enforce === true;
221
+ }
222
+ /** Check if interactive metadata prompting is enabled (default: true). */
223
+ function isInteractive() {
224
+ const config = loadConfigFile();
225
+ return config.naming?.interactive !== false;
226
+ }
227
+ /** Valid note types for the interactive prompt. */
228
+ export const NOTE_TYPES = [
229
+ 'user-preference',
230
+ 'feedback',
231
+ 'architecture-decision',
232
+ 'project-status',
233
+ 'reference',
234
+ 'event',
235
+ 'error',
236
+ 'knowledge-guide',
237
+ 'general',
238
+ ];
239
+ /** Valid statuses for notes. */
240
+ export const NOTE_STATUSES = ['idea', 'planning', 'active', 'done'];
241
+ /**
242
+ * Check if metadata is complete enough to skip interactive prompting.
243
+ * Returns null if complete, or a structured prompt message if fields are missing.
244
+ */
245
+ export function checkMetadataCompleteness(metadata, type) {
246
+ const missing = [];
247
+ if (!metadata.description) {
248
+ missing.push('description');
249
+ }
250
+ if (!metadata.upsert_key) {
251
+ missing.push('upsert_key');
252
+ }
253
+ // Only ask for status on project-scoped types
254
+ const projectTypes = ['architecture-decision', 'project-status', 'event', 'error'];
255
+ if (projectTypes.includes(type) && !metadata.status) {
256
+ missing.push('status');
257
+ }
258
+ if (missing.length === 0)
259
+ return null;
260
+ const fields = [];
261
+ if (missing.includes('description')) {
262
+ fields.push('- **description**: One line explaining what this note IS and what it\'s FOR');
263
+ }
264
+ if (missing.includes('upsert_key')) {
265
+ fields.push('- **upsert_key**: A unique identifier for this note (lowercase-hyphenated, e.g., "feedback-my-rule" or "myproject-spec-feature")');
266
+ }
267
+ if (missing.includes('status')) {
268
+ fields.push('- **status**: What stage is this? Options: idea, planning, active, done');
269
+ }
270
+ return `METADATA NEEDED — ask the user for these fields before saving:\n\n${fields.join('\n')}\n\nIf the user wants to skip, re-call add_note with metadata field \`interactive_skip: true\` to use defaults.`;
271
+ }
161
272
  // --- Shared Operations (called by both MCP and CLI) ---
162
273
  export async function opSearchNotes(clients, query, threshold, limit, type, project) {
163
274
  const embedding = await getEmbedding(clients.openai, query);
@@ -257,8 +368,34 @@ export async function opListNotes(clients, limit, type, project) {
257
368
  return { status: 'ok', message: formatted };
258
369
  }
259
370
  export async function opAddNote(clients, content, type, agent, metadata, force) {
260
- const fullMetadata = { ...metadata, type, agent, content_hash: contentHash(content) };
261
371
  const upsertKey = metadata.upsert_key;
372
+ const description = metadata.description;
373
+ const skippedInteractive = metadata.interactive_skip === true;
374
+ // Interactive metadata prompting (default: on, opt-out via config)
375
+ // Skip if user explicitly opted out via interactive_skip flag
376
+ if (!skippedInteractive && isInteractive()) {
377
+ const prompt = checkMetadataCompleteness(metadata, type);
378
+ if (prompt) {
379
+ return { status: 'confirm', message: prompt };
380
+ }
381
+ }
382
+ // Clean up the skip flag before saving
383
+ delete metadata.interactive_skip;
384
+ // Naming enforcement (opt-in via config)
385
+ if (isNamingEnforced()) {
386
+ const namingError = validateNaming(upsertKey || '', type, description);
387
+ if (namingError) {
388
+ return { status: 'error', message: `Naming violation: ${namingError}` };
389
+ }
390
+ }
391
+ // Auto-derive local_file from upsert_key for persona notes
392
+ if (upsertKey && !metadata.local_file) {
393
+ const delivery = metadata.delivery || inferDelivery(type);
394
+ if (delivery === 'persona') {
395
+ metadata.local_file = deriveLocalFile(upsertKey);
396
+ }
397
+ }
398
+ const fullMetadata = { ...metadata, type, agent, content_hash: contentHash(content) };
262
399
  // Duplicate guard: if no upsert_key and not forced, check for similar notes
263
400
  if (!upsertKey && !force) {
264
401
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aperdomoll90/ledger-ai",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "AI identity and memory system — portable persona, knowledge sync, semantic search across agents and devices",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,10 @@
18
18
  "prepublishOnly": "npm run build && npm test",
19
19
  "test": "vitest run",
20
20
  "test:watch": "vitest",
21
- "typecheck": "tsc --noEmit"
21
+ "typecheck": "tsc --noEmit",
22
+ "preversion": "npm run typecheck && npm test",
23
+ "ship": "bash scripts/ship.sh",
24
+ "release": "npm version patch && npm publish --access public"
22
25
  },
23
26
  "keywords": [
24
27
  "ai",