@aperdomoll90/ledger-ai 1.1.2 → 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.',
@@ -27,8 +13,15 @@ const DESCRIPTIONS = {
27
13
  writeInterception: 'Auto-ingest files written to memory directory into Ledger',
28
14
  sessionEndCheck: 'Check for unsynced files at session end',
29
15
  };
16
+ const NAMING_DESCRIPTIONS = {
17
+ 'naming.enforce': 'Validate upsert_key format on note creation',
18
+ 'naming.interactive': 'Prompt for missing metadata when creating notes (default: true)',
19
+ };
20
+ const DEVICE_DESCRIPTIONS = {
21
+ 'device.alias': 'Name for this device',
22
+ };
30
23
  export async function configGet(key) {
31
- const config = loadFullConfig();
24
+ const config = loadConfigFile();
32
25
  if (key === 'all') {
33
26
  console.log(JSON.stringify(config, null, 2));
34
27
  return;
@@ -44,6 +37,20 @@ export async function configGet(key) {
44
37
  console.log(`${key}: ${config[key]}`);
45
38
  return;
46
39
  }
40
+ // Check naming settings
41
+ if (key === 'naming.enforce') {
42
+ console.log(`naming.enforce: ${config.naming?.enforce ?? false}`);
43
+ return;
44
+ }
45
+ if (key === 'naming.interactive') {
46
+ console.log(`naming.interactive: ${config.naming?.interactive ?? true} (default: true)`);
47
+ return;
48
+ }
49
+ // Check device
50
+ if (key === 'device.alias') {
51
+ console.log(`device.alias: ${config.device?.alias || '(not set)'}`);
52
+ return;
53
+ }
47
54
  // Show default
48
55
  const defaults = {
49
56
  envBlocking: true,
@@ -55,12 +62,42 @@ export async function configGet(key) {
55
62
  console.log(`${key}: ${defaults[key]} (default)`);
56
63
  return;
57
64
  }
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'];
58
95
  console.error(`Unknown config key: ${key}`);
59
- console.error(`Available: ${Object.keys(DESCRIPTIONS).join(', ')}, memoryDir, claudeMdPath, all`);
96
+ console.error(`Available: ${allKeys.join(', ')}`);
60
97
  process.exit(1);
61
98
  }
62
- export async function configSet(key, value) {
63
- const config = loadFullConfig();
99
+ export async function configSet(key, value, clients) {
100
+ const config = loadConfigFile();
64
101
  // Handle hook settings
65
102
  const hookKeys = ['envBlocking', 'mcpJsonBlocking', 'writeInterception', 'sessionEndCheck'];
66
103
  if (hookKeys.includes(key)) {
@@ -82,25 +119,146 @@ export async function configSet(key, value) {
82
119
  if (!config.hooks)
83
120
  config.hooks = {};
84
121
  config.hooks[key] = boolValue;
85
- saveConfig(config);
122
+ saveConfigFile(config);
86
123
  const state = boolValue ? 'enabled' : 'disabled';
87
124
  console.error(`${key}: ${state}`);
88
125
  console.error('Run `ledger setup claude-code` to apply hook changes.');
89
126
  return;
90
127
  }
128
+ // Handle naming settings
129
+ const namingKeys = ['naming.enforce', 'naming.interactive'];
130
+ if (namingKeys.includes(key)) {
131
+ const boolValue = value === 'true';
132
+ if (!config.naming)
133
+ config.naming = {};
134
+ const field = key.split('.')[1];
135
+ config.naming[field] = boolValue;
136
+ saveConfigFile(config);
137
+ console.error(`${key}: ${boolValue ? 'enabled' : 'disabled'}`);
138
+ return;
139
+ }
140
+ // Handle device alias
141
+ if (key === 'device.alias') {
142
+ config.device = { alias: value };
143
+ saveConfigFile(config);
144
+ console.error(`device.alias: ${value}`);
145
+ return;
146
+ }
91
147
  // Handle path settings
92
148
  if (key === 'memoryDir' || key === 'claudeMdPath') {
93
149
  config[key] = value;
94
- saveConfig(config);
150
+ saveConfigFile(config);
95
151
  console.error(`${key}: ${value}`);
96
152
  return;
97
153
  }
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.*'];
98
218
  console.error(`Unknown config key: ${key}`);
99
- console.error(`Available: ${hookKeys.join(', ')}, memoryDir, claudeMdPath`);
219
+ console.error(`Available: ${allKeys.join(', ')}`);
100
220
  process.exit(1);
101
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
+ }
102
260
  export async function configList() {
103
- const config = loadFullConfig();
261
+ const config = loadConfigFile();
104
262
  const hooks = config.hooks || {};
105
263
  console.error('Hook settings:');
106
264
  for (const [key, desc] of Object.entries(DESCRIPTIONS)) {
@@ -108,8 +266,33 @@ export async function configList() {
108
266
  const state = value ? 'enabled' : 'DISABLED';
109
267
  console.error(` ${key}: ${state} — ${desc}`);
110
268
  }
269
+ console.error('\nNaming settings:');
270
+ for (const [key, desc] of Object.entries(NAMING_DESCRIPTIONS)) {
271
+ const field = key.split('.')[1];
272
+ const defaultVal = field === 'interactive' ? true : false;
273
+ const value = config.naming?.[field] ?? defaultVal;
274
+ const state = value ? 'enabled' : 'DISABLED';
275
+ console.error(` ${key}: ${state} — ${desc}`);
276
+ }
277
+ console.error('\nDevice:');
278
+ console.error(` device.alias: ${config.device?.alias || '(not set)'}`);
111
279
  console.error('\nPaths:');
112
280
  console.error(` memoryDir: ${config.memoryDir || '(default)'}`);
113
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}`);
114
297
  console.error('\nConfig file: ' + CONFIG_PATH);
115
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.2",
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": {