@aperdomoll90/ledger-ai 1.1.3 → 1.3.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.
@@ -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,
@@ -0,0 +1,163 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { resolve, join } from 'path';
3
+ // --- Config Templates ---
4
+ // Keep shared TS rules in sync between ESLINT_TS and ESLINT_TS_REACT
5
+ export const ESLINT_TS = `import tseslint from 'typescript-eslint';
6
+ import importPlugin from 'eslint-plugin-import';
7
+
8
+ export default tseslint.config(
9
+ ...tseslint.configs.recommended,
10
+ {
11
+ plugins: {
12
+ import: importPlugin,
13
+ },
14
+ rules: {
15
+ '@typescript-eslint/no-explicit-any': 'error',
16
+ '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
17
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
18
+ 'import/no-default-export': 'error',
19
+ 'max-lines': ['warn', { max: 200, skipBlankLines: true, skipComments: true }],
20
+ 'no-console': ['warn', { allow: ['error', 'warn'] }],
21
+ },
22
+ },
23
+ );
24
+ `;
25
+ export const ESLINT_TS_REACT = `import tseslint from 'typescript-eslint';
26
+ import reactPlugin from 'eslint-plugin-react';
27
+ import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
28
+ import importPlugin from 'eslint-plugin-import';
29
+
30
+ export default tseslint.config(
31
+ ...tseslint.configs.recommended,
32
+ {
33
+ plugins: {
34
+ react: reactPlugin,
35
+ 'jsx-a11y': jsxA11yPlugin,
36
+ import: importPlugin,
37
+ },
38
+ settings: {
39
+ react: { version: 'detect' },
40
+ },
41
+ rules: {
42
+ '@typescript-eslint/no-explicit-any': 'error',
43
+ '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
44
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
45
+ 'react/button-has-type': 'error',
46
+ 'import/no-default-export': 'error',
47
+ 'jsx-a11y/anchor-is-valid': 'warn',
48
+ 'jsx-a11y/click-events-have-key-events': 'warn',
49
+ 'max-lines': ['warn', { max: 200, skipBlankLines: true, skipComments: true }],
50
+ 'no-console': ['warn', { allow: ['error', 'warn'] }],
51
+ },
52
+ },
53
+ );
54
+ `;
55
+ // Backwards compat alias
56
+ export const ESLINT_UNIVERSAL = ESLINT_TS_REACT;
57
+ export const ESLINT_PERSONAL = `import universalConfig from './eslint.config.js';
58
+
59
+ export default [
60
+ ...universalConfig,
61
+ {
62
+ rules: {
63
+ 'no-warning-comments': ['warn', { terms: ['TODO', 'FIXME', 'HACK'] }],
64
+ },
65
+ },
66
+ ];
67
+ `;
68
+ export const STYLELINT_UNIVERSAL = JSON.stringify({
69
+ extends: ['stylelint-config-standard-scss'],
70
+ rules: {
71
+ 'unit-disallowed-list': ['vh', 'vw'],
72
+ 'declaration-property-unit-allowed-list': {
73
+ padding: ['rem', '%'],
74
+ margin: ['rem', '%'],
75
+ gap: ['rem', '%'],
76
+ 'border-width': ['px'],
77
+ 'font-size': ['rem'],
78
+ },
79
+ 'color-no-hex': [true, { severity: 'warning' }],
80
+ 'declaration-no-important': true,
81
+ 'declaration-block-no-duplicate-properties': true,
82
+ 'shorthand-property-no-redundant-values': [true, { severity: 'warning' }],
83
+ },
84
+ }, null, 2);
85
+ export const STYLELINT_PERSONAL = JSON.stringify({
86
+ extends: ['./.stylelintrc.json'],
87
+ rules: {
88
+ 'selector-class-pattern': [
89
+ '^c-[a-z][a-z0-9]*(__[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*)?$',
90
+ {
91
+ message: 'Class names must use BEM with c- prefix (e.g. c-block__element-modifier)',
92
+ },
93
+ ],
94
+ 'unit-disallowed-list': ['vh', 'vw', 'dvh'],
95
+ 'media-feature-name-disallowed-list': ['max-width'],
96
+ 'max-nesting-depth': [3, { severity: 'warning' }],
97
+ },
98
+ }, null, 2);
99
+ function hasScssFiles(dir) {
100
+ let entries;
101
+ try {
102
+ entries = readdirSync(dir);
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ for (const entry of entries) {
108
+ if (entry === 'node_modules')
109
+ continue;
110
+ const fullPath = join(dir, entry);
111
+ let stat;
112
+ try {
113
+ stat = statSync(fullPath);
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ if (stat.isDirectory()) {
119
+ if (hasScssFiles(fullPath))
120
+ return true;
121
+ }
122
+ else if (entry.endsWith('.scss')) {
123
+ return true;
124
+ }
125
+ }
126
+ return false;
127
+ }
128
+ export function detectStack(projectDir) {
129
+ const packageJsonPath = resolve(projectDir, 'package.json');
130
+ const tsConfigPath = resolve(projectDir, 'tsconfig.json');
131
+ const hasPackageJson = existsSync(packageJsonPath);
132
+ const hasTypeScript = existsSync(tsConfigPath);
133
+ let hasReact = false;
134
+ if (hasPackageJson) {
135
+ try {
136
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
137
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
138
+ hasReact = 'react' in deps;
139
+ }
140
+ catch {
141
+ // leave hasReact false
142
+ }
143
+ }
144
+ const hasScss = hasScssFiles(projectDir);
145
+ return { hasPackageJson, hasTypeScript, hasReact, hasScss };
146
+ }
147
+ export function getConfigsForStack(stack, personal) {
148
+ const needsEslint = stack.hasTypeScript || stack.hasReact;
149
+ const needsStylelint = stack.hasScss;
150
+ const eslint = needsEslint
151
+ ? {
152
+ filename: personal ? 'eslint.config.personal.js' : 'eslint.config.js',
153
+ content: personal ? ESLINT_PERSONAL : (stack.hasReact ? ESLINT_TS_REACT : ESLINT_TS),
154
+ }
155
+ : null;
156
+ const stylelint = needsStylelint
157
+ ? {
158
+ filename: personal ? '.stylelintrc.personal.json' : '.stylelintrc.json',
159
+ content: personal ? STYLELINT_PERSONAL : STYLELINT_UNIVERSAL,
160
+ }
161
+ : null;
162
+ return { eslint, stylelint };
163
+ }
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) {
@@ -31,7 +72,6 @@ export async function findNoteByFile(supabase, filename) {
31
72
  .from('notes')
32
73
  .select('id, metadata')
33
74
  .eq('metadata->>local_file', filename)
34
- .limit(1)
35
75
  .single();
36
76
  if (byFile)
37
77
  return byFile;
@@ -40,7 +80,6 @@ export async function findNoteByFile(supabase, filename) {
40
80
  .from('notes')
41
81
  .select('id, metadata')
42
82
  .eq('metadata->>upsert_key', upsertKey)
43
- .limit(1)
44
83
  .single();
45
84
  return byKey || null;
46
85
  }
@@ -164,12 +203,16 @@ function formatNotePreview(id, meta, content, maxLen = 300) {
164
203
  const TYPE_PREFIXES = {
165
204
  'feedback': ['feedback'],
166
205
  'user-preference': ['user'],
206
+ 'persona-rule': ['persona-rule'],
207
+ 'system-rule': ['system-rule'],
208
+ 'code-craft': ['code-craft'],
167
209
  'architecture-decision': ['spec', 'architecture'],
168
210
  'project-status': ['project-status'],
169
211
  'reference': ['reference'],
170
212
  'event': ['devlog', 'event'],
171
213
  'error': ['errorlog', 'error'],
172
214
  'general': ['general'],
215
+ 'knowledge-guide': ['knowledge-guide'],
173
216
  };
174
217
  /**
175
218
  * Validate upsert_key format: {prefix}-{topic} or {project}-{prefix}-{topic}
@@ -224,18 +267,6 @@ function isInteractive() {
224
267
  const config = loadConfigFile();
225
268
  return config.naming?.interactive !== false;
226
269
  }
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
270
  /** Valid statuses for notes. */
240
271
  export const NOTE_STATUSES = ['idea', 'planning', 'active', 'done'];
241
272
  /**
@@ -251,8 +282,7 @@ export function checkMetadataCompleteness(metadata, type) {
251
282
  missing.push('upsert_key');
252
283
  }
253
284
  // Only ask for status on project-scoped types
254
- const projectTypes = ['architecture-decision', 'project-status', 'event', 'error'];
255
- if (projectTypes.includes(type) && !metadata.status) {
285
+ if (inferDelivery(type) === 'project' && !metadata.status) {
256
286
  missing.push('status');
257
287
  }
258
288
  if (missing.length === 0)
@@ -367,7 +397,7 @@ export async function opListNotes(clients, limit, type, project) {
367
397
  }).join('\n\n---\n\n');
368
398
  return { status: 'ok', message: formatted };
369
399
  }
370
- export async function opAddNote(clients, content, type, agent, metadata, force) {
400
+ export async function opAddNote(clients, content, type, agent, metadata, force, registerTypeFlag) {
371
401
  const upsertKey = metadata.upsert_key;
372
402
  const description = metadata.description;
373
403
  const skippedInteractive = metadata.interactive_skip === true;
@@ -381,6 +411,29 @@ export async function opAddNote(clients, content, type, agent, metadata, force)
381
411
  }
382
412
  // Clean up the skip flag before saving
383
413
  delete metadata.interactive_skip;
414
+ // --- Type resolution and registration ---
415
+ const resolvedType = resolveTypeAlias(type);
416
+ // If register_type flag is set and type is unknown, register it
417
+ if (registerTypeFlag && !isRegisteredType(resolvedType)) {
418
+ const nameError = validateTypeName(resolvedType);
419
+ if (nameError)
420
+ return { status: 'error', message: nameError };
421
+ const delivery = metadata.delivery || 'knowledge';
422
+ registerType(resolvedType, delivery);
423
+ }
424
+ // If type is still unknown after potential registration, prompt
425
+ if (!isRegisteredType(resolvedType)) {
426
+ const registry = getTypeRegistry();
427
+ const typeListStr = Object.entries(registry)
428
+ .map(([t, d]) => `${t} (${d})`)
429
+ .join(', ');
430
+ return {
431
+ 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.`,
433
+ };
434
+ }
435
+ // Use resolved type for the rest of the flow
436
+ type = resolvedType;
384
437
  // Naming enforcement (opt-in via config)
385
438
  if (isNamingEnforced()) {
386
439
  const namingError = validateNaming(upsertKey || '', type, description);
@@ -430,7 +483,6 @@ export async function opAddNote(clients, content, type, agent, metadata, force)
430
483
  .from('notes')
431
484
  .select('id, metadata, created_at')
432
485
  .eq('metadata->>upsert_key', upsertKey)
433
- .limit(1)
434
486
  .single();
435
487
  if (existing) {
436
488
  return upsertExistingNote(clients, existing, content, fullMetadata);
@@ -531,6 +583,16 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
531
583
  if (fetchError || !existing) {
532
584
  return { status: 'error', message: `Error: note ${id} not found.` };
533
585
  }
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
+ }
534
596
  // Confirmation gate
535
597
  if (!confirmed) {
536
598
  const currentPreview = formatNotePreview(existing.id, existing.metadata, existing.content);
@@ -602,7 +664,7 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
602
664
  }
603
665
  return { status: 'ok', message: `Note updated as ${chunks.length} chunks (ids: ${ids.join(', ')}, group: ${newGroupId})` };
604
666
  }
605
- export async function opUpdateMetadata(clients, id, metadata) {
667
+ export async function opUpdateMetadata(clients, id, metadata, confirmed = false) {
606
668
  const { data: existing, error: fetchError } = await clients.supabase
607
669
  .from('notes')
608
670
  .select('id, metadata')
@@ -611,6 +673,16 @@ export async function opUpdateMetadata(clients, id, metadata) {
611
673
  if (fetchError || !existing) {
612
674
  return { status: 'error', message: `Error: note ${id} not found.` };
613
675
  }
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
+ }
614
686
  const merged = { ...existing.metadata, ...metadata };
615
687
  const { error } = await clients.supabase
616
688
  .from('notes')
@@ -632,6 +704,15 @@ export async function opDeleteNote(clients, id, confirmed = false) {
632
704
  }
633
705
  const meta = existing.metadata;
634
706
  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
+ }
635
716
  // Confirmation gate
636
717
  if (!confirmed) {
637
718
  const chunkInfo = groupId ? ` (chunked, all chunks will be deleted)` : '';
@@ -652,3 +733,31 @@ export async function opDeleteNote(clients, id, confirmed = false) {
652
733
  return { status: 'error', message: `Error: ${error.message}` };
653
734
  return { status: 'ok', message: `Note ${id} deleted.` };
654
735
  }
736
+ export async function checkChunkIntegrity(supabase) {
737
+ const { data: chunkedNotes, error } = await supabase
738
+ .from('notes')
739
+ .select('id, metadata')
740
+ .not('metadata->>chunk_group', 'is', null);
741
+ if (error || !chunkedNotes)
742
+ return { incompleteGroups: [] };
743
+ const groups = new Map();
744
+ for (const note of chunkedNotes) {
745
+ const meta = note.metadata;
746
+ const groupId = meta.chunk_group;
747
+ if (!groups.has(groupId))
748
+ groups.set(groupId, []);
749
+ groups.get(groupId).push({
750
+ id: note.id,
751
+ index: meta.chunk_index,
752
+ total: meta.total_chunks,
753
+ });
754
+ }
755
+ const incompleteGroups = [];
756
+ for (const [groupId, chunks] of groups) {
757
+ const expected = chunks[0].total;
758
+ if (chunks.length !== expected) {
759
+ incompleteGroups.push({ groupId, expected, found: chunks.length });
760
+ }
761
+ }
762
+ return { incompleteGroups };
763
+ }