@aperdomoll90/ledger-ai 1.3.0 → 1.4.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.
Files changed (84) hide show
  1. package/dist/cli.js +177 -221
  2. package/dist/commands/add.js +51 -100
  3. package/dist/commands/backfill.js +55 -0
  4. package/dist/commands/backup.js +10 -10
  5. package/dist/commands/check.js +21 -29
  6. package/dist/commands/config.js +13 -12
  7. package/dist/commands/delete.js +22 -17
  8. package/dist/commands/eval-judge.js +11 -0
  9. package/dist/commands/eval.js +321 -0
  10. package/dist/commands/export.js +8 -10
  11. package/dist/commands/get.js +9 -0
  12. package/dist/commands/hunt.js +206 -0
  13. package/dist/commands/ingest.js +15 -14
  14. package/dist/commands/init.js +18 -20
  15. package/dist/commands/list.js +21 -7
  16. package/dist/commands/migrate.js +11 -11
  17. package/dist/commands/onboard.js +2 -2
  18. package/dist/commands/pull.js +3 -2
  19. package/dist/commands/push.js +8 -8
  20. package/dist/commands/restore.js +38 -38
  21. package/dist/commands/show.js +13 -16
  22. package/dist/commands/sync.js +58 -19
  23. package/dist/commands/tag.js +20 -14
  24. package/dist/commands/update.js +50 -18
  25. package/dist/commands/wizard.js +3 -3
  26. package/dist/lib/ai-search.js +163 -0
  27. package/dist/lib/audit.js +19 -0
  28. package/dist/lib/backfill.js +60 -0
  29. package/dist/lib/config.js +19 -2
  30. package/dist/lib/document-classification.js +5 -0
  31. package/dist/lib/document-fetching.js +77 -0
  32. package/dist/lib/document-operations.js +150 -0
  33. package/dist/lib/documents/classification.js +5 -0
  34. package/dist/lib/documents/fetching.js +89 -0
  35. package/dist/lib/documents/operations.js +304 -0
  36. package/dist/lib/domains.js +116 -0
  37. package/dist/lib/embeddings.js +190 -0
  38. package/dist/lib/errors.js +3 -1
  39. package/dist/lib/eval/eval-advanced.js +289 -0
  40. package/dist/lib/eval/eval-judge-session.js +233 -0
  41. package/dist/lib/eval/eval-store.js +105 -0
  42. package/dist/lib/eval/eval.js +303 -0
  43. package/dist/lib/file-writer.js +23 -0
  44. package/dist/lib/generators.js +44 -45
  45. package/dist/lib/hunter-db.js +235 -0
  46. package/dist/lib/hunter-rss.js +30 -0
  47. package/dist/lib/hunter-scoring.js +55 -0
  48. package/dist/lib/hunter-types.js +36 -0
  49. package/dist/lib/lint-configs.js +20 -0
  50. package/dist/lib/migrate.js +2 -2
  51. package/dist/lib/notes.js +173 -59
  52. package/dist/lib/observability.js +296 -0
  53. package/dist/lib/op-add-note-types.test.js +7 -6
  54. package/dist/lib/prompt.js +8 -8
  55. package/dist/lib/rate-limiter.js +103 -0
  56. package/dist/lib/search/ai-search.js +396 -0
  57. package/dist/lib/search/chunk-context-enrichment.js +155 -0
  58. package/dist/lib/search/embeddings.js +293 -0
  59. package/dist/lib/search/reranker.js +120 -0
  60. package/dist/lib/search/semantic-cache.js +53 -0
  61. package/dist/lib/type-registry.test.js +6 -6
  62. package/dist/mcp-server.js +553 -66
  63. package/dist/migrations/migrations/005-audit-log.sql +22 -0
  64. package/dist/migrations/migrations/005_opportunities.sql +48 -0
  65. package/dist/migrations/migrations/006-audited-operations.sql +235 -0
  66. package/dist/migrations/migrations/006_hunt_analytics.sql +38 -0
  67. package/dist/migrations/migrations/007-eval-golden-judgments.sql +119 -0
  68. package/dist/migrations/migrations/008-drop-expected-doc-ids.sql +9 -0
  69. package/dist/migrations/migrations/008-judge-helpers.sql +21 -0
  70. package/dist/migrations/migrations/009-semantic-cache.sql +216 -0
  71. package/dist/scripts/batch-grade.js +344 -0
  72. package/dist/scripts/benchmark-ingestion.js +376 -0
  73. package/dist/scripts/convert-judgments-to-graded.js +88 -0
  74. package/dist/scripts/diagnose-first-result.js +333 -0
  75. package/dist/scripts/drop-golden-query.js +53 -0
  76. package/dist/scripts/eval-search.js +115 -0
  77. package/dist/scripts/grade-unjudged-top1.js +138 -0
  78. package/dist/scripts/hunter-analytics.js +38 -0
  79. package/dist/scripts/hunter-cron.js +63 -0
  80. package/dist/scripts/hunter-purge.js +25 -0
  81. package/dist/scripts/migrate-v2.js +140 -0
  82. package/dist/scripts/reindex.js +74 -0
  83. package/dist/scripts/sync-local-docs.js +153 -0
  84. package/package.json +7 -1
package/dist/lib/notes.js CHANGED
@@ -1,41 +1,75 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { fatal, ExitCode } from './errors.js';
3
3
  import { contentHash } from './hash.js';
4
+ import { audit } from './audit.js';
4
5
  import { loadConfigFile, saveConfigFile } from './config.js';
5
- // --- Built-in Type Registry ---
6
+ import { inferDomain as inferDomainFromType, getProtectionDefault, getAutoLoadDefault, resolveV1Type, isV2Type, } from './domains.js';
7
+ // --- Built-in Type Registry (v2: domain-based) ---
6
8
  export const BUILTIN_TYPES = {
9
+ // v2 types mapped to legacy delivery tier for backward compat
10
+ 'personality': 'persona',
11
+ 'behavioral-rule': 'persona',
12
+ 'preference': 'persona',
13
+ 'skill': 'persona',
14
+ 'claude-md': 'persona',
15
+ 'hook': 'persona',
16
+ 'plugin-config': 'persona',
17
+ 'type-registry': 'persona',
18
+ 'sync-rule': 'persona',
19
+ 'dashboard': 'project',
20
+ 'device-registry': 'project',
21
+ 'environment': 'project',
22
+ 'eval-result': 'project',
23
+ 'architecture': 'project',
24
+ 'project-status': 'project',
25
+ 'event': 'project',
26
+ 'error': 'project',
27
+ 'reference': 'knowledge',
28
+ 'knowledge': 'knowledge',
29
+ 'general': 'knowledge',
30
+ // Legacy v1 names still accepted
7
31
  'user-preference': 'persona',
8
32
  'persona-rule': 'persona',
9
33
  'system-rule': 'persona',
10
34
  'code-craft': 'persona',
11
35
  'architecture-decision': 'project',
12
- 'project-status': 'project',
13
- 'event': 'project',
14
- 'error': 'project',
15
- 'reference': 'knowledge',
16
36
  'knowledge-guide': 'knowledge',
17
- 'general': 'knowledge',
18
- };
19
- const TYPE_ALIASES = {
20
- 'feedback': 'general',
37
+ 'skill-reference': 'persona',
21
38
  };
22
39
  function resolveTypeAlias(type) {
23
- return TYPE_ALIASES[type] ?? type;
40
+ const migration = resolveV1Type(type);
41
+ if (migration)
42
+ return migration.type;
43
+ if (type === 'feedback')
44
+ return 'general';
45
+ return type;
24
46
  }
25
47
  export function getTypeRegistry() {
26
48
  const config = loadConfigFile();
27
49
  return { ...BUILTIN_TYPES, ...(config.types ?? {}) };
28
50
  }
51
+ /** Infer domain from a note type. Handles both v1 and v2 type names. */
52
+ export function inferDomain(noteType) {
53
+ const v1 = resolveV1Type(noteType);
54
+ if (v1)
55
+ return v1.domain;
56
+ return inferDomainFromType(noteType);
57
+ }
58
+ /** Legacy: infer delivery tier from note type. Use inferDomain for new code. */
29
59
  export function inferDelivery(noteType) {
60
+ const registry = getTypeRegistry();
61
+ // Check original name first (user overrides may use v1 names)
62
+ if (noteType in registry)
63
+ return registry[noteType];
30
64
  const resolved = resolveTypeAlias(noteType);
31
- return getTypeRegistry()[resolved] ?? 'knowledge';
65
+ return registry[resolved] ?? 'knowledge';
32
66
  }
33
67
  export function getRegisteredTypes() {
34
68
  return Object.keys(getTypeRegistry());
35
69
  }
36
70
  export function isRegisteredType(noteType) {
37
71
  const resolved = resolveTypeAlias(noteType);
38
- return resolved in getTypeRegistry();
72
+ return isV2Type(resolved) || resolved in getTypeRegistry();
39
73
  }
40
74
  export function registerType(name, delivery) {
41
75
  const config = loadConfigFile();
@@ -61,17 +95,31 @@ export async function fetchPersonaNotes(supabase) {
61
95
  const { data, error } = await supabase
62
96
  .from('notes')
63
97
  .select('id, content, metadata, created_at, updated_at')
64
- .eq('metadata->>delivery', 'persona');
98
+ .eq('metadata->>domain', 'persona');
65
99
  if (error) {
66
100
  fatal(`Error querying persona notes: ${error.message}`, ExitCode.SUPABASE_ERROR);
67
101
  }
68
102
  return (data || []);
69
103
  }
104
+ /**
105
+ * Fetch notes that should sync to every machine.
106
+ * v2: domain IN (system, persona, workspace).
107
+ */
108
+ export async function fetchSyncableNotes(supabase) {
109
+ const { data: domainNotes, error: domainError } = await supabase
110
+ .from('notes')
111
+ .select('id, content, metadata, created_at, updated_at')
112
+ .in('metadata->>domain', ['system', 'persona', 'workspace']);
113
+ if (!domainError && domainNotes && domainNotes.length > 0) {
114
+ return domainNotes;
115
+ }
116
+ return fetchPersonaNotes(supabase);
117
+ }
70
118
  export async function findNoteByFile(supabase, filename) {
71
119
  const { data: byFile } = await supabase
72
120
  .from('notes')
73
121
  .select('id, metadata')
74
- .eq('metadata->>local_file', filename)
122
+ .eq('metadata->>file_path', filename)
75
123
  .single();
76
124
  if (byFile)
77
125
  return byFile;
@@ -120,10 +168,10 @@ export async function searchNotes(supabase, openai, query, threshold = 0.3, maxR
120
168
  export async function fetchNoteHashes(supabase) {
121
169
  const notes = await fetchPersonaNotes(supabase);
122
170
  return notes
123
- .filter(n => n.metadata.local_file)
171
+ .filter(n => n.metadata.file_path)
124
172
  .map(n => ({
125
173
  id: n.id,
126
- localFile: n.metadata.local_file,
174
+ localFile: n.metadata.file_path,
127
175
  contentHash: n.metadata.content_hash || contentHash(n.content),
128
176
  content: n.content,
129
177
  }));
@@ -198,6 +246,35 @@ function formatNotePreview(id, meta, content, maxLen = 300) {
198
246
  const descLine = desc ? `\nDescription: ${desc}` : '';
199
247
  return `"${label}" (id: ${id}) | type: ${noteType || '-'} | project: ${project || '-'}${descLine}\n ${preview}${truncated}`;
200
248
  }
249
+ /**
250
+ * Check if an operation should be blocked or confirmed based on protection level.
251
+ * Returns null if operation is allowed, or an OperationResult if blocked/needs approval.
252
+ */
253
+ function checkProtection(noteId, meta, operation, confirmed) {
254
+ const protection = meta.protection ?? 'open';
255
+ const uKey = meta.upsert_key;
256
+ const label = uKey || `id ${noteId}`;
257
+ const noteType = meta.type;
258
+ if (protection === 'immutable') {
259
+ return {
260
+ status: 'error',
261
+ message: `BLOCKED — "${label}" (type: ${noteType ?? 'unknown'}) is immutable and cannot be ${operation}d. Immutable notes are system-managed only.`,
262
+ };
263
+ }
264
+ if (protection === 'protected' && !confirmed) {
265
+ return {
266
+ status: 'confirm',
267
+ message: `PROTECTED NOTE — "${label}" (type: ${noteType ?? 'unknown'}) requires explicit user approval to ${operation}.\n\nTo proceed, re-call with confirmed: true.`,
268
+ };
269
+ }
270
+ if (protection === 'guarded' && !confirmed) {
271
+ return {
272
+ status: 'confirm',
273
+ message: `GUARDED NOTE — "${label}" (type: ${noteType ?? 'unknown'}) requires confirmation to ${operation}.\n\nTo proceed, re-call with confirmed: true.`,
274
+ };
275
+ }
276
+ return null;
277
+ }
201
278
  // --- Naming Conventions ---
202
279
  /** Valid type prefixes for upsert_key naming. */
203
280
  const TYPE_PREFIXES = {
@@ -253,8 +330,8 @@ export function validateNaming(upsertKey, type, description) {
253
330
  }
254
331
  return null;
255
332
  }
256
- /** Derive local_file from upsert_key: feedback-style → feedback_style.md */
257
- export function deriveLocalFile(upsertKey) {
333
+ /** Derive file_path from upsert_key: feedback-style → feedback_style.md */
334
+ export function deriveFilePath(upsertKey) {
258
335
  return upsertKey.replace(/-/g, '_') + '.md';
259
336
  }
260
337
  /** Check if naming enforcement is enabled in config. */
@@ -281,7 +358,7 @@ export function checkMetadataCompleteness(metadata, type) {
281
358
  if (!metadata.upsert_key) {
282
359
  missing.push('upsert_key');
283
360
  }
284
- // Only ask for status on project-scoped types
361
+ // Only ask for status on project-tier types (those with delivery=project in the type registry)
285
362
  if (inferDelivery(type) === 'project' && !metadata.status) {
286
363
  missing.push('status');
287
364
  }
@@ -300,9 +377,9 @@ export function checkMetadataCompleteness(metadata, type) {
300
377
  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.`;
301
378
  }
302
379
  // --- Shared Operations (called by both MCP and CLI) ---
303
- export async function opSearchNotes(clients, query, threshold, limit, type, project) {
380
+ export async function opSearchNotes(clients, query, threshold, limit, type, project, domain) {
304
381
  const embedding = await getEmbedding(clients.openai, query);
305
- const fetchLimit = (type || project) ? limit * 3 : limit;
382
+ const fetchLimit = (type || project || domain) ? limit * 3 : limit;
306
383
  const { data, error } = await clients.supabase.rpc('match_notes', {
307
384
  q_emb: JSON.stringify(embedding),
308
385
  threshold,
@@ -316,6 +393,8 @@ export async function opSearchNotes(clients, query, threshold, limit, type, proj
316
393
  results = results.filter(n => n.metadata.type === type);
317
394
  if (project)
318
395
  results = results.filter(n => n.metadata.project === project);
396
+ if (domain)
397
+ results = results.filter(n => n.metadata.domain === domain);
319
398
  results = results.slice(0, limit);
320
399
  // Fallback: retry at 0.3 when requested threshold returns empty
321
400
  if ((!results || results.length === 0) && threshold > 0.3) {
@@ -330,6 +409,8 @@ export async function opSearchNotes(clients, query, threshold, limit, type, proj
330
409
  fallbackResults = fallbackResults.filter(n => n.metadata.type === type);
331
410
  if (project)
332
411
  fallbackResults = fallbackResults.filter(n => n.metadata.project === project);
412
+ if (domain)
413
+ fallbackResults = fallbackResults.filter(n => n.metadata.domain === domain);
333
414
  fallbackResults = fallbackResults.slice(0, limit);
334
415
  if (fallbackResults.length > 0) {
335
416
  results = fallbackResults;
@@ -373,7 +454,7 @@ async function reassembleResults(supabase, results) {
373
454
  }
374
455
  return output.join('\n\n---\n\n');
375
456
  }
376
- export async function opListNotes(clients, limit, type, project) {
457
+ export async function opListNotes(clients, limit, type, project, domain) {
377
458
  let query = clients.supabase
378
459
  .from('notes')
379
460
  .select('id, content, metadata, created_at')
@@ -383,6 +464,8 @@ export async function opListNotes(clients, limit, type, project) {
383
464
  query = query.eq('metadata->>type', type);
384
465
  if (project)
385
466
  query = query.eq('metadata->>project', project);
467
+ if (domain)
468
+ query = query.eq('metadata->>domain', domain);
386
469
  const { data, error } = await query;
387
470
  if (error) {
388
471
  return { status: 'error', message: `Error: ${error.message}` };
@@ -418,8 +501,10 @@ export async function opAddNote(clients, content, type, agent, metadata, force,
418
501
  const nameError = validateTypeName(resolvedType);
419
502
  if (nameError)
420
503
  return { status: 'error', message: nameError };
421
- const delivery = metadata.delivery || 'knowledge';
422
- registerType(resolvedType, delivery);
504
+ const domainForRegistry = inferDomain(resolvedType);
505
+ // Map domain to legacy delivery tier for registry compatibility
506
+ const deliveryForRegistry = domainForRegistry === 'project' ? 'project' : domainForRegistry === 'persona' || domainForRegistry === 'system' ? 'persona' : 'knowledge';
507
+ registerType(resolvedType, deliveryForRegistry);
423
508
  }
424
509
  // If type is still unknown after potential registration, prompt
425
510
  if (!isRegisteredType(resolvedType)) {
@@ -429,11 +514,32 @@ export async function opAddNote(clients, content, type, agent, metadata, force,
429
514
  .join(', ');
430
515
  return {
431
516
  status: 'confirm',
432
- 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.`,
517
+ message: `Type "${type}" is not registered.\n\nOptions:\n1. Register with domain auto-inferred from type — re-call add_note with register_type: true\n2. Register with specific domain — re-call add_note with register_type: true AND set metadata.domain to "system", "persona", "workspace", or "project"\n3. Use an existing type instead — re-call add_note with one of: ${typeListStr}\n4. Cancel\n\nAsk the user which option they prefer.`,
433
518
  };
434
519
  }
435
520
  // Use resolved type for the rest of the flow
436
521
  type = resolvedType;
522
+ // --- Auto-set v2 metadata from type ---
523
+ if (!metadata.domain) {
524
+ metadata.domain = inferDomain(type);
525
+ }
526
+ if (!metadata.protection) {
527
+ metadata.protection = getProtectionDefault(type);
528
+ }
529
+ if (metadata.auto_load === undefined) {
530
+ metadata.auto_load = getAutoLoadDefault(metadata.domain, type);
531
+ }
532
+ if (!metadata.owner_type) {
533
+ metadata.owner_type = 'user';
534
+ metadata.owner_id = null;
535
+ }
536
+ if (!metadata.schema_version) {
537
+ metadata.schema_version = 1;
538
+ }
539
+ if (!metadata.embedding_model) {
540
+ metadata.embedding_model = 'openai/text-embedding-3-small';
541
+ metadata.embedding_dimensions = 1536;
542
+ }
437
543
  // Naming enforcement (opt-in via config)
438
544
  if (isNamingEnforced()) {
439
545
  const namingError = validateNaming(upsertKey || '', type, description);
@@ -441,11 +547,11 @@ export async function opAddNote(clients, content, type, agent, metadata, force,
441
547
  return { status: 'error', message: `Naming violation: ${namingError}` };
442
548
  }
443
549
  }
444
- // Auto-derive local_file from upsert_key for persona notes
445
- if (upsertKey && !metadata.local_file) {
446
- const delivery = metadata.delivery || inferDelivery(type);
447
- if (delivery === 'persona') {
448
- metadata.local_file = deriveLocalFile(upsertKey);
550
+ // Auto-derive file_path from upsert_key for persona/system domain notes with auto_load
551
+ if (upsertKey && !metadata.file_path) {
552
+ const noteDomain = metadata.domain || inferDomain(type);
553
+ if (noteDomain === 'persona' || noteDomain === 'system') {
554
+ metadata.file_path = deriveFilePath(upsertKey);
449
555
  }
450
556
  }
451
557
  const fullMetadata = { ...metadata, type, agent, content_hash: contentHash(content) };
@@ -507,6 +613,7 @@ async function upsertExistingNote(clients, existing, content, fullMetadata) {
507
613
  .single();
508
614
  if (error)
509
615
  return { status: 'error', message: `Error: ${error.message}` };
616
+ await audit(clients, data.id, fullMetadata, 'update', { content_hash: existing.metadata.content_hash, metadata: existing.metadata });
510
617
  const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
511
618
  return { status: 'ok', message: `Updated "${upsertKey}" (id: ${data.id}, ${content.length} chars)\nPreview: ${preview}` };
512
619
  }
@@ -527,6 +634,7 @@ async function upsertExistingNote(clients, existing, content, fullMetadata) {
527
634
  .single();
528
635
  if (error)
529
636
  return { status: 'error', message: `Error: ${error.message}` };
637
+ await audit(clients, data.id, fullMetadata, 'update', { content_hash: existing.metadata.content_hash, metadata: existing.metadata });
530
638
  const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
531
639
  return { status: 'ok', message: `Updated "${upsertKey}" (id: ${data.id}, ${content.length} chars)\nPreview: ${preview}` };
532
640
  }
@@ -544,6 +652,7 @@ async function upsertExistingNote(clients, existing, content, fullMetadata) {
544
652
  return { status: 'error', message: `Error saving chunk ${i + 1}/${newChunks.length}: ${error.message}` };
545
653
  ids.push(data.id);
546
654
  }
655
+ await audit(clients, ids[0], fullMetadata, 'update', { content_hash: existing.metadata.content_hash, metadata: existing.metadata });
547
656
  return { status: 'ok', message: `Updated "${upsertKey}" as ${newChunks.length} chunks (ids: ${ids.join(', ')}, ${content.length} chars total)` };
548
657
  }
549
658
  async function createNewNote(clients, content, fullMetadata) {
@@ -558,6 +667,7 @@ async function createNewNote(clients, content, fullMetadata) {
558
667
  .single();
559
668
  if (error)
560
669
  return { status: 'error', message: `Error: ${error.message}` };
670
+ await audit(clients, data.id, fullMetadata, 'create', null);
561
671
  const label = upsertKey || `id ${data.id}`;
562
672
  const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
563
673
  return { status: 'ok', message: `Saved "${label}" (id: ${data.id}, type: ${fullMetadata.type}, ${content.length} chars)\nPreview: ${preview}` };
@@ -572,6 +682,7 @@ async function createNewNote(clients, content, fullMetadata) {
572
682
  return { status: 'error', message: `Error saving chunk ${i + 1}/${chunks.length}: ${error.message}` };
573
683
  ids.push(data.id);
574
684
  }
685
+ await audit(clients, ids[0], fullMetadata, 'create', null);
575
686
  return { status: 'ok', message: `Saved "${upsertKey || 'chunked'}" as ${chunks.length} chunks (ids: ${ids.join(', ')}, ${content.length} chars total)` };
576
687
  }
577
688
  export async function opUpdateNote(clients, id, content, metadata, confirmed = false) {
@@ -583,16 +694,9 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
583
694
  if (fetchError || !existing) {
584
695
  return { status: 'error', message: `Error: note ${id} not found.` };
585
696
  }
586
- // Protected delivery gate skill-reference and other protected notes require explicit confirmation
587
- const existingDelivery = existing.metadata.delivery;
588
- if (existingDelivery === 'protected' && !confirmed) {
589
- const existingType = existing.metadata.type;
590
- const uKey = existing.metadata.upsert_key;
591
- return {
592
- status: 'confirm',
593
- message: `⚠️ PROTECTED NOTE — "${uKey || `id ${id}`}" (type: ${existingType || 'unknown'}) has protected delivery.\n\nThis is a skill-reference note. Are you sure you want to update it? Skill reference documents are protected to prevent accidental overwrites.\n\nTo proceed, call update_note again with confirmed: true.`,
594
- };
595
- }
697
+ const protectionCheck = checkProtection(id, existing.metadata, 'update', confirmed);
698
+ if (protectionCheck)
699
+ return protectionCheck;
596
700
  // Confirmation gate
597
701
  if (!confirmed) {
598
702
  const currentPreview = formatNotePreview(existing.id, existing.metadata, existing.content);
@@ -619,6 +723,7 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
619
723
  .single();
620
724
  if (error)
621
725
  return { status: 'error', message: `Error: ${error.message}` };
726
+ await audit(clients, data.id, existingMeta, 'update', { content: existing.content, metadata: existingMeta });
622
727
  const uKey = baseMeta.upsert_key;
623
728
  const label = uKey || `id ${data.id}`;
624
729
  const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
@@ -643,6 +748,7 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
643
748
  .single();
644
749
  if (error)
645
750
  return { status: 'error', message: `Error: ${error.message}` };
751
+ await audit(clients, data.id, existingMeta, 'update', { content: existing.content, metadata: existingMeta });
646
752
  const uKey = baseMeta.upsert_key;
647
753
  const label = uKey || `id ${data.id}`;
648
754
  const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
@@ -662,6 +768,7 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
662
768
  return { status: 'error', message: `Error updating chunk ${i + 1}/${chunks.length}: ${error.message}` };
663
769
  ids.push(data.id);
664
770
  }
771
+ await audit(clients, ids[0], existingMeta, 'update', { content: existing.content, metadata: existingMeta });
665
772
  return { status: 'ok', message: `Note updated as ${chunks.length} chunks (ids: ${ids.join(', ')}, group: ${newGroupId})` };
666
773
  }
667
774
  export async function opUpdateMetadata(clients, id, metadata, confirmed = false) {
@@ -673,23 +780,35 @@ export async function opUpdateMetadata(clients, id, metadata, confirmed = false)
673
780
  if (fetchError || !existing) {
674
781
  return { status: 'error', message: `Error: note ${id} not found.` };
675
782
  }
676
- // Protected delivery gate
677
- const existingDelivery = existing.metadata.delivery;
678
- if (existingDelivery === 'protected' && !confirmed) {
679
- const existingType = existing.metadata.type;
680
- const uKey = existing.metadata.upsert_key;
681
- return {
682
- status: 'confirm',
683
- message: `⚠️ PROTECTED NOTE — "${uKey || `id ${id}`}" (type: ${existingType || 'unknown'}) has protected delivery.\n\nThis is a skill-reference note. Are you sure you want to update its metadata?\n\nTo proceed, call update_metadata again with confirmed: true.`,
684
- };
685
- }
783
+ const protectionCheck = checkProtection(id, existing.metadata, 'update', confirmed);
784
+ if (protectionCheck)
785
+ return protectionCheck;
686
786
  const merged = { ...existing.metadata, ...metadata };
787
+ // Type change cascading — auto-update domain when type changes
788
+ const oldType = existing.metadata.type;
789
+ const newType = metadata.type;
790
+ if (newType && oldType && newType !== oldType) {
791
+ const oldExpectedDomain = inferDomain(oldType);
792
+ const currentDomain = existing.metadata.domain;
793
+ // Only auto-update if domain wasn't manually overridden
794
+ if (!currentDomain || currentDomain === oldExpectedDomain) {
795
+ merged.domain = inferDomain(newType);
796
+ }
797
+ // If domain was manually set to something different, leave it alone
798
+ }
687
799
  const { error } = await clients.supabase
688
800
  .from('notes')
689
801
  .update({ metadata: merged })
690
802
  .eq('id', id);
691
803
  if (error)
692
804
  return { status: 'error', message: `Error: ${error.message}` };
805
+ const changedFields = {};
806
+ for (const key of Object.keys(metadata)) {
807
+ const oldVal = existing.metadata[key];
808
+ if (oldVal !== metadata[key])
809
+ changedFields[key] = oldVal;
810
+ }
811
+ await audit(clients, id, merged, 'update_metadata', { metadata: changedFields });
693
812
  const uKey = merged.upsert_key;
694
813
  return { status: 'ok', message: `Updated metadata for "${uKey || `id ${id}`}"` };
695
814
  }
@@ -704,15 +823,9 @@ export async function opDeleteNote(clients, id, confirmed = false) {
704
823
  }
705
824
  const meta = existing.metadata;
706
825
  const groupId = meta.chunk_group;
707
- // Protected delivery gate
708
- if (meta.delivery === 'protected' && !confirmed) {
709
- const uKey = meta.upsert_key;
710
- const existingType = meta.type;
711
- return {
712
- status: 'confirm',
713
- message: `⚠️ PROTECTED NOTE — "${uKey || `id ${id}`}" (type: ${existingType || 'unknown'}) has protected delivery.\n\nThis is a skill-reference note. Are you sure you want to delete it? This action cannot be undone.\n\nTo proceed, call delete_note again with confirmed: true.`,
714
- };
715
- }
826
+ const protectionCheck = checkProtection(id, meta, 'delete', confirmed);
827
+ if (protectionCheck)
828
+ return protectionCheck;
716
829
  // Confirmation gate
717
830
  if (!confirmed) {
718
831
  const chunkInfo = groupId ? ` (chunked, all chunks will be deleted)` : '';
@@ -722,6 +835,7 @@ export async function opDeleteNote(clients, id, confirmed = false) {
722
835
  message: `CONFIRM DELETE — This note will be permanently removed${chunkInfo}:\n\n${preview}\n\nTo proceed, call delete_note again with confirmed: true.`,
723
836
  };
724
837
  }
838
+ await audit(clients, id, meta, 'delete', { content: existing.content, metadata: meta });
725
839
  if (groupId) {
726
840
  const { error } = await clients.supabase.from('notes').delete().eq('metadata->>chunk_group', groupId);
727
841
  if (error)