@aperdomoll90/ledger-ai 1.1.3 → 1.2.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/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ import { setupClaudeCode, setupOpenclaw, setupChatgpt } from './commands/setup.j
17
17
  import { backup, enableBackupCron, disableBackupCron } from './commands/backup.js';
18
18
  import { restore } from './commands/restore.js';
19
19
  import { onboard } from './commands/onboard.js';
20
- import { configGet, configSet, configList } from './commands/config.js';
20
+ import { configGet, configSet, configUnset, configList } from './commands/config.js';
21
21
  import { migrate } from './commands/migrate.js';
22
22
  import { add } from './commands/add.js';
23
23
  import { update } from './commands/update.js';
@@ -133,7 +133,25 @@ configCmd
133
133
  .command('set <key> <value>')
134
134
  .description('Set a config value')
135
135
  .action(async (key, value) => {
136
- await configSet(key, value);
136
+ if (key.startsWith('types.')) {
137
+ const config = loadConfig();
138
+ await configSet(key, value, { supabase: config.supabase, openai: config.openai });
139
+ }
140
+ else {
141
+ await configSet(key, value);
142
+ }
143
+ });
144
+ configCmd
145
+ .command('unset <key>')
146
+ .description('Remove a config override (types.* keys only)')
147
+ .action(async (key) => {
148
+ if (key.startsWith('types.')) {
149
+ const config = loadConfig();
150
+ await configUnset(key, { supabase: config.supabase, openai: config.openai });
151
+ }
152
+ else {
153
+ await configUnset(key);
154
+ }
137
155
  });
138
156
  configCmd
139
157
  .command('list')
@@ -1,5 +1,5 @@
1
1
  import { loadConfigFile } from '../lib/config.js';
2
- import { opAddNote, NOTE_TYPES, NOTE_STATUSES } from '../lib/notes.js';
2
+ import { opAddNote, getRegisteredTypes, isRegisteredType, registerType, validateTypeName, inferDelivery, NOTE_STATUSES } from '../lib/notes.js';
3
3
  import { ask, confirm, choose } from '../lib/prompt.js';
4
4
  export async function add(config, content, options) {
5
5
  const configFile = loadConfigFile();
@@ -18,12 +18,37 @@ export async function add(config, content, options) {
18
18
  if (interactive && !options.force) {
19
19
  // Type
20
20
  if (!type) {
21
+ const registeredTypes = getRegisteredTypes();
21
22
  const typeChoice = await choose('What type of note is this?', [
22
- ...NOTE_TYPES,
23
+ ...registeredTypes,
23
24
  'skip — use default (general)',
24
25
  ]);
25
26
  type = typeChoice.startsWith('skip') ? 'general' : typeChoice;
26
27
  }
28
+ // Handle unknown type from --type flag
29
+ if (type && !isRegisteredType(type)) {
30
+ console.error(`\nType "${type}" is not registered.`);
31
+ const action = await choose('What would you like to do?', [
32
+ 'register — register it now (pick a delivery tier)',
33
+ 'existing — use an existing type instead',
34
+ 'proceed — save anyway (defaults to "knowledge" delivery)',
35
+ ]);
36
+ if (action.startsWith('register')) {
37
+ const nameError = validateTypeName(type);
38
+ if (nameError) {
39
+ console.error(nameError);
40
+ process.exit(1);
41
+ }
42
+ const deliveryChoice = await choose('Delivery tier?', ['persona', 'project', 'knowledge']);
43
+ registerType(type, deliveryChoice);
44
+ console.error(`Registered type "${type}" with delivery "${deliveryChoice}".`);
45
+ }
46
+ else if (action.startsWith('existing')) {
47
+ const registeredTypes = getRegisteredTypes();
48
+ type = await choose('Choose a type:', registeredTypes);
49
+ }
50
+ // 'proceed' — use the type as-is, will default to knowledge delivery
51
+ }
27
52
  // Description
28
53
  if (!metadata.description) {
29
54
  const desc = await ask('One-line description (what is this note for?): ');
@@ -43,8 +68,7 @@ export async function add(config, content, options) {
43
68
  metadata.project = proj;
44
69
  }
45
70
  // Status (only for project-scoped types)
46
- const projectTypes = ['architecture-decision', 'project-status', 'event', 'error'];
47
- if (projectTypes.includes(type) && !metadata.status) {
71
+ if (inferDelivery(type) === 'project' && !metadata.status) {
48
72
  const statusChoice = await choose('What stage is this?', [
49
73
  ...NOTE_STATUSES,
50
74
  'skip — no status',
@@ -1,22 +1,8 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'fs';
1
+ import { getLedgerDir, saveConfigFile, loadConfigFile } from '../lib/config.js';
2
+ import { BUILTIN_TYPES, getTypeRegistry, opUpdateMetadata, validateTypeName } from '../lib/notes.js';
3
+ import { choose, confirm } from '../lib/prompt.js';
2
4
  import { resolve } from 'path';
3
- import { getLedgerDir } from '../lib/config.js';
4
- import { confirm } from '../lib/prompt.js';
5
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
6
  const SECURITY_WARNINGS = {
21
7
  envBlocking: 'This disables .env file protection.\nYour API keys and credentials will be readable by the AI agent.',
22
8
  mcpJsonBlocking: 'This allows direct editing of mcp.json.\nMCP servers should be registered via `claude mcp add`, not by editing config files.',
@@ -35,7 +21,7 @@ const DEVICE_DESCRIPTIONS = {
35
21
  'device.alias': 'Name for this device',
36
22
  };
37
23
  export async function configGet(key) {
38
- const config = loadFullConfig();
24
+ const config = loadConfigFile();
39
25
  if (key === 'all') {
40
26
  console.log(JSON.stringify(config, null, 2));
41
27
  return;
@@ -76,13 +62,42 @@ export async function configGet(key) {
76
62
  console.log(`${key}: ${defaults[key]} (default)`);
77
63
  return;
78
64
  }
79
- const allKeys = [...Object.keys(DESCRIPTIONS), ...Object.keys(NAMING_DESCRIPTIONS), ...Object.keys(DEVICE_DESCRIPTIONS), 'memoryDir', 'claudeMdPath', 'all'];
65
+ // Handle type registry
66
+ if (key === 'types') {
67
+ const registry = getTypeRegistry();
68
+ const userTypes = config.types ?? {};
69
+ console.error('Type registry:');
70
+ for (const [typeName, delivery] of Object.entries(registry)) {
71
+ const isBuiltin = typeName in BUILTIN_TYPES;
72
+ const isOverridden = isBuiltin && typeName in userTypes;
73
+ const isCustom = !isBuiltin;
74
+ let annotation = isCustom ? '(custom)' : '(built-in)';
75
+ if (isOverridden) {
76
+ annotation = `(built-in, overridden — default: ${BUILTIN_TYPES[typeName]})`;
77
+ }
78
+ console.error(` ${typeName}: ${delivery} ${annotation}`);
79
+ }
80
+ return;
81
+ }
82
+ if (key.startsWith('types.')) {
83
+ const typeName = key.slice(6);
84
+ const registry = getTypeRegistry();
85
+ if (typeName in registry) {
86
+ const isBuiltin = typeName in BUILTIN_TYPES;
87
+ console.log(`${typeName}: ${registry[typeName]} (${isBuiltin ? 'built-in' : 'custom'})`);
88
+ }
89
+ else {
90
+ console.log(`${typeName}: not registered`);
91
+ }
92
+ return;
93
+ }
94
+ const allKeys = [...Object.keys(DESCRIPTIONS), ...Object.keys(NAMING_DESCRIPTIONS), ...Object.keys(DEVICE_DESCRIPTIONS), 'memoryDir', 'claudeMdPath', 'types', 'all'];
80
95
  console.error(`Unknown config key: ${key}`);
81
96
  console.error(`Available: ${allKeys.join(', ')}`);
82
97
  process.exit(1);
83
98
  }
84
- export async function configSet(key, value) {
85
- const config = loadFullConfig();
99
+ export async function configSet(key, value, clients) {
100
+ const config = loadConfigFile();
86
101
  // Handle hook settings
87
102
  const hookKeys = ['envBlocking', 'mcpJsonBlocking', 'writeInterception', 'sessionEndCheck'];
88
103
  if (hookKeys.includes(key)) {
@@ -104,7 +119,7 @@ export async function configSet(key, value) {
104
119
  if (!config.hooks)
105
120
  config.hooks = {};
106
121
  config.hooks[key] = boolValue;
107
- saveConfig(config);
122
+ saveConfigFile(config);
108
123
  const state = boolValue ? 'enabled' : 'disabled';
109
124
  console.error(`${key}: ${state}`);
110
125
  console.error('Run `ledger setup claude-code` to apply hook changes.');
@@ -118,31 +133,132 @@ export async function configSet(key, value) {
118
133
  config.naming = {};
119
134
  const field = key.split('.')[1];
120
135
  config.naming[field] = boolValue;
121
- saveConfig(config);
136
+ saveConfigFile(config);
122
137
  console.error(`${key}: ${boolValue ? 'enabled' : 'disabled'}`);
123
138
  return;
124
139
  }
125
140
  // Handle device alias
126
141
  if (key === 'device.alias') {
127
142
  config.device = { alias: value };
128
- saveConfig(config);
143
+ saveConfigFile(config);
129
144
  console.error(`device.alias: ${value}`);
130
145
  return;
131
146
  }
132
147
  // Handle path settings
133
148
  if (key === 'memoryDir' || key === 'claudeMdPath') {
134
149
  config[key] = value;
135
- saveConfig(config);
150
+ saveConfigFile(config);
136
151
  console.error(`${key}: ${value}`);
137
152
  return;
138
153
  }
139
- const allKeys = [...hookKeys, ...namingKeys, 'device.alias', 'memoryDir', 'claudeMdPath'];
154
+ // Handle type registry
155
+ if (key.startsWith('types.')) {
156
+ const typeName = key.slice(6);
157
+ const delivery = value;
158
+ if (!['persona', 'project', 'knowledge'].includes(delivery)) {
159
+ console.error(`Invalid delivery tier: "${value}". Must be: persona, project, or knowledge.`);
160
+ process.exit(1);
161
+ }
162
+ const nameError = validateTypeName(typeName);
163
+ if (nameError) {
164
+ console.error(nameError);
165
+ process.exit(1);
166
+ }
167
+ const oldDelivery = config.types?.[typeName] ?? BUILTIN_TYPES[typeName];
168
+ if (!config.types)
169
+ config.types = {};
170
+ config.types[typeName] = delivery;
171
+ saveConfigFile(config);
172
+ const isBuiltin = typeName in BUILTIN_TYPES;
173
+ const action = isBuiltin ? 'overridden' : 'registered';
174
+ console.error(`types.${typeName}: ${delivery} (${action})`);
175
+ // Delivery change propagation — only if we have DB access and delivery actually changed
176
+ if (clients && oldDelivery && oldDelivery !== delivery) {
177
+ const { data: notes } = await clients.supabase
178
+ .from('notes')
179
+ .select('id, metadata')
180
+ .eq('metadata->>type', typeName);
181
+ const affected = (notes ?? []).filter((n) => n.metadata.delivery !== delivery);
182
+ if (affected.length > 0) {
183
+ console.error(`\n${affected.length} note(s) currently have a different delivery:`);
184
+ for (const note of affected) {
185
+ const meta = note.metadata;
186
+ const uKey = meta.upsert_key || `id-${note.id}`;
187
+ console.error(` [${note.id}] ${uKey} — delivery: ${meta.delivery}`);
188
+ }
189
+ const action = await choose('\nUpdate delivery on these notes?', [
190
+ 'all — update all notes',
191
+ 'select — choose individually',
192
+ 'none — only affect new notes',
193
+ ]);
194
+ if (action.startsWith('all')) {
195
+ for (const note of affected) {
196
+ await opUpdateMetadata(clients, note.id, { delivery });
197
+ }
198
+ console.error(`Updated delivery to "${delivery}" on ${affected.length} note(s).`);
199
+ }
200
+ else if (action.startsWith('select')) {
201
+ let updated = 0;
202
+ for (const note of affected) {
203
+ const meta = note.metadata;
204
+ const uKey = meta.upsert_key || `id-${note.id}`;
205
+ const yes = await confirm(` Update [${note.id}] ${uKey}?`);
206
+ if (yes) {
207
+ await opUpdateMetadata(clients, note.id, { delivery });
208
+ updated++;
209
+ }
210
+ }
211
+ console.error(`Updated delivery on ${updated} note(s).`);
212
+ }
213
+ }
214
+ }
215
+ return;
216
+ }
217
+ const allKeys = [...hookKeys, ...namingKeys, 'device.alias', 'memoryDir', 'claudeMdPath', 'types.*'];
140
218
  console.error(`Unknown config key: ${key}`);
141
219
  console.error(`Available: ${allKeys.join(', ')}`);
142
220
  process.exit(1);
143
221
  }
222
+ export async function configUnset(key, clients) {
223
+ if (!key.startsWith('types.')) {
224
+ console.error(`Unset is only supported for types.* keys. Got: ${key}`);
225
+ process.exit(1);
226
+ }
227
+ const typeName = key.slice(6);
228
+ const config = loadConfigFile();
229
+ const userTypes = config.types ?? {};
230
+ if (!(typeName in userTypes)) {
231
+ console.error(`No user override for "${typeName}".`);
232
+ return;
233
+ }
234
+ const isBuiltin = typeName in BUILTIN_TYPES;
235
+ if (!isBuiltin && clients) {
236
+ const { data: notes } = await clients.supabase
237
+ .from('notes')
238
+ .select('id')
239
+ .eq('metadata->>type', typeName);
240
+ if (notes && notes.length > 0) {
241
+ console.error(`\n${notes.length} note(s) use type "${typeName}". They will become unregistered (delivery defaults to "knowledge").`);
242
+ const proceed = await confirm('Proceed?');
243
+ if (!proceed) {
244
+ console.error('Cancelled.');
245
+ return;
246
+ }
247
+ }
248
+ }
249
+ delete config.types[typeName];
250
+ if (config.types && Object.keys(config.types).length === 0)
251
+ delete config.types;
252
+ saveConfigFile(config);
253
+ if (isBuiltin) {
254
+ console.error(`Reverted "${typeName}" to built-in default: ${BUILTIN_TYPES[typeName]}`);
255
+ }
256
+ else {
257
+ console.error(`Removed custom type "${typeName}".`);
258
+ }
259
+ }
144
260
  export async function configList() {
145
- const config = loadFullConfig();
261
+ const config = loadConfigFile();
146
262
  const hooks = config.hooks || {};
147
263
  console.error('Hook settings:');
148
264
  for (const [key, desc] of Object.entries(DESCRIPTIONS)) {
@@ -163,5 +279,20 @@ export async function configList() {
163
279
  console.error('\nPaths:');
164
280
  console.error(` memoryDir: ${config.memoryDir || '(default)'}`);
165
281
  console.error(` claudeMdPath: ${config.claudeMdPath || '(default)'}`);
282
+ const registry = getTypeRegistry();
283
+ const userTypes = config.types ?? {};
284
+ const customTypes = Object.keys(userTypes).filter(t => !(t in BUILTIN_TYPES));
285
+ const overrides = Object.keys(userTypes).filter(t => t in BUILTIN_TYPES);
286
+ console.error('\nType registry:');
287
+ if (customTypes.length > 0) {
288
+ console.error(` Custom types: ${customTypes.map(t => `${t} (${userTypes[t]})`).join(', ')}`);
289
+ }
290
+ if (overrides.length > 0) {
291
+ console.error(` Overrides: ${overrides.map(t => `${t}: ${userTypes[t]} (default: ${BUILTIN_TYPES[t]})`).join(', ')}`);
292
+ }
293
+ if (customTypes.length === 0 && overrides.length === 0) {
294
+ console.error(' No custom types or overrides. Using built-in defaults.');
295
+ }
296
+ console.error(` Built-in types: ${Object.keys(BUILTIN_TYPES).length}`);
166
297
  console.error('\nConfig file: ' + CONFIG_PATH);
167
298
  }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, readdirSync } from 'fs';
2
2
  import { resolve } from 'path';
3
- import { fetchPersonaNotes, updateNoteContent, updateNoteHash } from '../lib/notes.js';
3
+ import { loadConfigFile, saveConfigFile } from '../lib/config.js';
4
+ import { fetchPersonaNotes, updateNoteContent, updateNoteHash, opAddNote } from '../lib/notes.js';
4
5
  import { contentHash } from '../lib/hash.js';
5
6
  import { generateClaudeMd, generateMemoryMd } from '../lib/generators.js';
6
7
  import { confirm } from '../lib/prompt.js';
@@ -20,6 +21,8 @@ export async function sync(config, options) {
20
21
  return result;
21
22
  }
22
23
  mkdirSync(config.memoryDir, { recursive: true });
24
+ // --- Phase 0: Sync type registry ---
25
+ await syncTypeRegistryPull(config, quiet, force, dryRun);
23
26
  const notesByFile = new Map();
24
27
  for (const note of notes) {
25
28
  const localFile = note.metadata.local_file;
@@ -191,6 +194,8 @@ export async function sync(config, options) {
191
194
  console.error(' wrote ~/CLAUDE.md');
192
195
  }
193
196
  }
197
+ // --- Phase 3.5: Push type registry ---
198
+ await syncTypeRegistryPush(config, quiet, dryRun);
194
199
  // --- Summary ---
195
200
  if (!quiet) {
196
201
  const parts = [
@@ -204,3 +209,69 @@ export async function sync(config, options) {
204
209
  }
205
210
  return result;
206
211
  }
212
+ export async function syncTypeRegistryPush(config, quiet, dryRun) {
213
+ const configFile = loadConfigFile();
214
+ const userTypes = configFile.types;
215
+ if (!userTypes || Object.keys(userTypes).length === 0) {
216
+ return; // Nothing to push
217
+ }
218
+ const content = JSON.stringify(userTypes, null, 2);
219
+ const clients = { supabase: config.supabase, openai: config.openai };
220
+ if (dryRun) {
221
+ if (!quiet)
222
+ console.error(' type-registry — would push to Ledger');
223
+ return;
224
+ }
225
+ await opAddNote(clients, content, 'system-rule', 'ledger-sync', {
226
+ upsert_key: 'system-rule-type-registry',
227
+ description: 'User-defined type registry overrides. Managed by ledger sync.',
228
+ delivery: 'persona',
229
+ scope: 'system',
230
+ interactive_skip: true,
231
+ }, true); // force: true to skip duplicate guard
232
+ if (!quiet)
233
+ console.error(' type-registry — pushed to Ledger');
234
+ }
235
+ export async function syncTypeRegistryPull(config, quiet, force, dryRun) {
236
+ const { data: note } = await config.supabase
237
+ .from('notes')
238
+ .select('content')
239
+ .eq('metadata->>upsert_key', 'system-rule-type-registry')
240
+ .limit(1)
241
+ .single();
242
+ if (!note)
243
+ return; // No remote type registry
244
+ let remoteTypes;
245
+ try {
246
+ remoteTypes = JSON.parse(note.content);
247
+ }
248
+ catch {
249
+ if (!quiet)
250
+ console.error(' type-registry — invalid JSON in remote note, skipping');
251
+ return;
252
+ }
253
+ const configFile = loadConfigFile();
254
+ const localTypes = configFile.types ?? {};
255
+ // Merge: local wins unless --force
256
+ const merged = force
257
+ ? { ...localTypes, ...remoteTypes }
258
+ : { ...remoteTypes, ...localTypes };
259
+ // Check if anything changed
260
+ const localJson = JSON.stringify(localTypes, Object.keys(localTypes).sort());
261
+ const mergedJson = JSON.stringify(merged, Object.keys(merged).sort());
262
+ if (localJson === mergedJson)
263
+ return; // No changes
264
+ if (dryRun) {
265
+ const newKeys = Object.keys(merged).filter(k => !(k in localTypes));
266
+ if (newKeys.length > 0 && !quiet) {
267
+ console.error(` type-registry — would add: ${newKeys.map(k => `${k} (${merged[k]})`).join(', ')}`);
268
+ }
269
+ return;
270
+ }
271
+ configFile.types = merged;
272
+ saveConfigFile(configFile);
273
+ const newKeys = Object.keys(merged).filter(k => !(k in localTypes));
274
+ if (newKeys.length > 0 && !quiet) {
275
+ console.error(` type-registry synced: added ${newKeys.map(k => `${k} (${merged[k]})`).join(', ')}`);
276
+ }
277
+ }
@@ -3,7 +3,7 @@ import { createClient } from '@supabase/supabase-js';
3
3
  import OpenAI from 'openai';
4
4
  import { resolve } from 'path';
5
5
  import { homedir } from 'os';
6
- import { existsSync, readFileSync } from 'fs';
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
7
  import { fatal, ExitCode } from './errors.js';
8
8
  // --- Defaults ---
9
9
  const LEDGER_DIR = resolve(homedir(), '.ledger');
@@ -28,6 +28,11 @@ export function loadConfigFile() {
28
28
  }
29
29
  return {};
30
30
  }
31
+ export function saveConfigFile(config) {
32
+ const configPath = resolve(getLedgerDir(), 'config.json');
33
+ mkdirSync(getLedgerDir(), { recursive: true });
34
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
35
+ }
31
36
  export function getDefaultConfig() {
32
37
  return {
33
38
  memoryDir: DEFAULT_MEMORY_DIR,
package/dist/lib/notes.js CHANGED
@@ -1,19 +1,60 @@
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';
5
- const DELIVERY_BY_TYPE = {
4
+ import { loadConfigFile, saveConfigFile } from './config.js';
5
+ // --- Built-in Type Registry ---
6
+ export const BUILTIN_TYPES = {
6
7
  'user-preference': 'persona',
7
- 'feedback': 'persona',
8
+ 'persona-rule': 'persona',
9
+ 'system-rule': 'persona',
10
+ 'code-craft': 'persona',
8
11
  'architecture-decision': 'project',
9
12
  'project-status': 'project',
10
- 'error': 'project',
11
13
  'event': 'project',
14
+ 'error': 'project',
12
15
  'reference': 'knowledge',
16
+ 'knowledge-guide': 'knowledge',
13
17
  'general': 'knowledge',
14
18
  };
19
+ const TYPE_ALIASES = {
20
+ 'feedback': 'general',
21
+ };
22
+ function resolveTypeAlias(type) {
23
+ return TYPE_ALIASES[type] ?? type;
24
+ }
25
+ export function getTypeRegistry() {
26
+ const config = loadConfigFile();
27
+ return { ...BUILTIN_TYPES, ...(config.types ?? {}) };
28
+ }
15
29
  export function inferDelivery(noteType) {
16
- return DELIVERY_BY_TYPE[noteType] ?? 'knowledge';
30
+ const resolved = resolveTypeAlias(noteType);
31
+ return getTypeRegistry()[resolved] ?? 'knowledge';
32
+ }
33
+ export function getRegisteredTypes() {
34
+ return Object.keys(getTypeRegistry());
35
+ }
36
+ export function isRegisteredType(noteType) {
37
+ const resolved = resolveTypeAlias(noteType);
38
+ return resolved in getTypeRegistry();
39
+ }
40
+ export function registerType(name, delivery) {
41
+ const config = loadConfigFile();
42
+ if (!config.types)
43
+ config.types = {};
44
+ config.types[name] = delivery;
45
+ saveConfigFile(config);
46
+ }
47
+ export function validateTypeName(name) {
48
+ if (!name || name.length < 2) {
49
+ return `Type name must be at least 2 characters. Got ${name.length}.`;
50
+ }
51
+ if (name.length > 50) {
52
+ return `Type name must be 50 characters or fewer. Got ${name.length}.`;
53
+ }
54
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) {
55
+ return `Invalid type name "${name}". Use lowercase alphanumeric + hyphens, starting with a letter (e.g., "wine-log").`;
56
+ }
57
+ return null;
17
58
  }
18
59
  // --- Queries ---
19
60
  export async function fetchPersonaNotes(supabase) {
@@ -164,12 +205,16 @@ function formatNotePreview(id, meta, content, maxLen = 300) {
164
205
  const TYPE_PREFIXES = {
165
206
  'feedback': ['feedback'],
166
207
  'user-preference': ['user'],
208
+ 'persona-rule': ['persona-rule'],
209
+ 'system-rule': ['system-rule'],
210
+ 'code-craft': ['code-craft'],
167
211
  'architecture-decision': ['spec', 'architecture'],
168
212
  'project-status': ['project-status'],
169
213
  'reference': ['reference'],
170
214
  'event': ['devlog', 'event'],
171
215
  'error': ['errorlog', 'error'],
172
216
  'general': ['general'],
217
+ 'knowledge-guide': ['knowledge-guide'],
173
218
  };
174
219
  /**
175
220
  * Validate upsert_key format: {prefix}-{topic} or {project}-{prefix}-{topic}
@@ -224,18 +269,6 @@ function isInteractive() {
224
269
  const config = loadConfigFile();
225
270
  return config.naming?.interactive !== false;
226
271
  }
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
272
  /** Valid statuses for notes. */
240
273
  export const NOTE_STATUSES = ['idea', 'planning', 'active', 'done'];
241
274
  /**
@@ -251,8 +284,7 @@ export function checkMetadataCompleteness(metadata, type) {
251
284
  missing.push('upsert_key');
252
285
  }
253
286
  // Only ask for status on project-scoped types
254
- const projectTypes = ['architecture-decision', 'project-status', 'event', 'error'];
255
- if (projectTypes.includes(type) && !metadata.status) {
287
+ if (inferDelivery(type) === 'project' && !metadata.status) {
256
288
  missing.push('status');
257
289
  }
258
290
  if (missing.length === 0)
@@ -367,7 +399,7 @@ export async function opListNotes(clients, limit, type, project) {
367
399
  }).join('\n\n---\n\n');
368
400
  return { status: 'ok', message: formatted };
369
401
  }
370
- export async function opAddNote(clients, content, type, agent, metadata, force) {
402
+ export async function opAddNote(clients, content, type, agent, metadata, force, registerTypeFlag) {
371
403
  const upsertKey = metadata.upsert_key;
372
404
  const description = metadata.description;
373
405
  const skippedInteractive = metadata.interactive_skip === true;
@@ -381,6 +413,29 @@ export async function opAddNote(clients, content, type, agent, metadata, force)
381
413
  }
382
414
  // Clean up the skip flag before saving
383
415
  delete metadata.interactive_skip;
416
+ // --- Type resolution and registration ---
417
+ const resolvedType = resolveTypeAlias(type);
418
+ // If register_type flag is set and type is unknown, register it
419
+ if (registerTypeFlag && !isRegisteredType(resolvedType)) {
420
+ const nameError = validateTypeName(resolvedType);
421
+ if (nameError)
422
+ return { status: 'error', message: nameError };
423
+ const delivery = metadata.delivery || 'knowledge';
424
+ registerType(resolvedType, delivery);
425
+ }
426
+ // If type is still unknown after potential registration, prompt
427
+ if (!isRegisteredType(resolvedType)) {
428
+ const registry = getTypeRegistry();
429
+ const typeListStr = Object.entries(registry)
430
+ .map(([t, d]) => `${t} (${d})`)
431
+ .join(', ');
432
+ return {
433
+ status: 'confirm',
434
+ message: `Type "${type}" is not registered.\n\nOptions:\n1. Register with delivery "knowledge" (default) — re-call add_note with register_type: true\n2. Register with specific delivery — re-call add_note with register_type: true AND set metadata.delivery to "persona", "project", or "knowledge"\n3. Use an existing type instead — re-call add_note with one of: ${typeListStr}\n4. Cancel\n\nAsk the user which option they prefer.`,
435
+ };
436
+ }
437
+ // Use resolved type for the rest of the flow
438
+ type = resolvedType;
384
439
  // Naming enforcement (opt-in via config)
385
440
  if (isNamingEnforced()) {
386
441
  const namingError = validateNaming(upsertKey || '', type, description);
@@ -4,7 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import { createClient } from '@supabase/supabase-js';
5
5
  import OpenAI from 'openai';
6
6
  import { z } from 'zod';
7
- import { opSearchNotes, opListNotes, opAddNote, opUpdateNote, opUpdateMetadata, opDeleteNote, } from './lib/notes.js';
7
+ import { opSearchNotes, opListNotes, opAddNote, opUpdateNote, opUpdateMetadata, opDeleteNote, getTypeRegistry, } from './lib/notes.js';
8
8
  // --- Clients ---
9
9
  const supabaseUrl = process.env.SUPABASE_URL;
10
10
  const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
@@ -21,6 +21,11 @@ const clients = {
21
21
  supabase: createClient(supabaseUrl, supabaseKey),
22
22
  openai: new OpenAI({ apiKey: openaiKey }),
23
23
  };
24
+ // Build dynamic type description from registry
25
+ const typeRegistry = getTypeRegistry();
26
+ const typeList = Object.entries(typeRegistry)
27
+ .map(([t, d]) => `${t} (${d})`)
28
+ .join(', ');
24
29
  // --- MCP Server ---
25
30
  const server = new McpServer({
26
31
  name: 'ledger',
@@ -40,12 +45,13 @@ server.tool('search_notes', 'Search memories by meaning using semantic similarit
40
45
  // Tool: Add a new note
41
46
  server.tool('add_note', 'Save a new memory/note to the knowledge base. Large notes are automatically chunked for embedding. Use upsert_key in metadata to update an existing note instead of creating a duplicate.', {
42
47
  content: z.string().describe('The note content to save'),
43
- type: z.enum(['user-preference', 'feedback', 'architecture-decision', 'project-status', 'reference', 'event', 'error', 'knowledge-guide', 'general']).describe('Note type for consistent categorization'),
48
+ type: z.string().describe(`Note type. Registered: ${typeList}. Unknown types will prompt for registration.`),
44
49
  agent: z.string().describe('Which agent is saving this note (e.g. claude-code, zhuli)'),
45
50
  metadata: z.record(z.string(), z.unknown()).default({}).describe('Optional metadata (project, local_file, upsert_key, etc.)'),
46
51
  force: z.boolean().default(false).describe('Skip duplicate check and force creation of a new note'),
47
- }, async ({ content, type, agent, metadata, force }) => {
48
- const result = await opAddNote(clients, content, type, agent, metadata, force);
52
+ register_type: z.boolean().default(false).describe('Set to true to register an unknown type before saving. Pass delivery in metadata if not using default (knowledge).'),
53
+ }, async ({ content, type, agent, metadata, force, register_type }) => {
54
+ const result = await opAddNote(clients, content, type, agent, metadata, force, register_type);
49
55
  return { content: [{ type: 'text', text: result.message }] };
50
56
  });
51
57
  // Tool: Update an existing note by ID
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aperdomoll90/ledger-ai",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
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": {