@aperdomoll90/ledger-ai 1.4.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
@@ -1,71 +1,71 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import { confirm } from '../lib/prompt.js';
4
+ import { createDocument } from '../lib/documents/operations.js';
4
5
  export async function restore(config, filePath) {
5
6
  const absPath = resolve(filePath);
6
7
  if (!existsSync(absPath)) {
7
8
  console.error(`File not found: ${absPath}`);
8
9
  process.exit(1);
9
10
  }
10
- let notes;
11
+ let documents;
11
12
  try {
12
- notes = JSON.parse(readFileSync(absPath, 'utf-8'));
13
+ documents = JSON.parse(readFileSync(absPath, 'utf-8'));
13
14
  }
14
15
  catch {
15
16
  console.error('Invalid JSON file.');
16
17
  process.exit(1);
17
18
  }
18
- console.error(`Found ${notes.length} notes in backup.`);
19
+ console.error(`Found ${documents.length} documents in backup.`);
19
20
  // Check current database
20
21
  const { count } = await config.supabase
21
- .from('notes')
22
+ .from('documents')
22
23
  .select('*', { count: 'exact', head: true });
23
24
  if (count && count > 0) {
24
- console.error(`Database already has ${count} notes.`);
25
- const proceed = await confirm('Restore will add notes (not replace). Continue?');
25
+ console.error(`Database already has ${count} documents.`);
26
+ const proceed = await confirm('Restore will add documents (not replace). Continue?');
26
27
  if (!proceed) {
27
28
  console.error('Cancelled.');
28
29
  return;
29
30
  }
30
31
  }
31
32
  console.error('Restoring...\n');
33
+ const clients = {
34
+ supabase: config.supabase,
35
+ openai: config.openai,
36
+ };
32
37
  let restored = 0;
33
38
  let skipped = 0;
34
- for (const note of notes) {
35
- // Check for existing note with same upsert_key
36
- const upsertKey = note.metadata.upsert_key;
37
- if (upsertKey) {
38
- const { data: existing } = await config.supabase
39
- .from('notes')
40
- .select('id')
41
- .eq('metadata->>upsert_key', upsertKey)
42
- .limit(1)
43
- .single();
44
- if (existing) {
45
- console.error(` skip "${upsertKey}" (already exists)`);
46
- skipped++;
47
- continue;
48
- }
39
+ for (const document of documents) {
40
+ // Check for existing document with same name (UNIQUE in v2)
41
+ const { data: existing } = await config.supabase
42
+ .from('documents')
43
+ .select('id')
44
+ .eq('name', document.name)
45
+ .limit(1)
46
+ .single();
47
+ if (existing) {
48
+ console.error(` skip "${document.name}" (already exists)`);
49
+ skipped++;
50
+ continue;
51
+ }
52
+ try {
53
+ await createDocument(clients, {
54
+ name: document.name,
55
+ domain: document.domain,
56
+ document_type: document.document_type,
57
+ project: document.project ?? undefined,
58
+ protection: document.protection,
59
+ content: document.content,
60
+ description: document.description ?? undefined,
61
+ status: document.status ?? undefined,
62
+ });
49
63
  }
50
- // Generate embedding
51
- const embeddingResponse = await config.openai.embeddings.create({
52
- model: 'text-embedding-3-small',
53
- input: note.content,
54
- });
55
- const embedding = embeddingResponse.data[0].embedding;
56
- const { error } = await config.supabase
57
- .from('notes')
58
- .insert({
59
- content: note.content,
60
- metadata: note.metadata,
61
- embedding,
62
- });
63
- if (error) {
64
- console.error(` error restoring note ${note.id}: ${error.message}`);
64
+ catch (error) {
65
+ console.error(` error restoring "${document.name}": ${error.message}`);
65
66
  continue;
66
67
  }
67
- const label = upsertKey || `note-${note.id}`;
68
- console.error(` restored "${label}"`);
68
+ console.error(` restored "${document.name}"`);
69
69
  restored++;
70
70
  }
71
71
  console.error(`\nRestore complete: ${restored} restored, ${skipped} skipped (already exist)`);
@@ -1,29 +1,26 @@
1
1
  import { writeFileSync, mkdirSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import { execFileSync } from 'child_process';
4
- import { searchNotes } from '../lib/notes.js';
4
+ import { searchHybrid } from '../lib/search/ai-search.js';
5
5
  import { fatal, ExitCode } from '../lib/errors.js';
6
6
  const VIEW_DIR = '/tmp/ledger-view';
7
7
  export async function show(config, query, options = {}) {
8
- // Fetch more if filtering
9
- const fetchLimit = (options.type || options.project) ? 10 : 1;
10
- let results = await searchNotes(config.supabase, config.openai, query, 0.3, fetchLimit);
11
- if (options.type) {
12
- results = results.filter(n => n.metadata.type === options.type);
13
- }
14
- if (options.project) {
15
- results = results.filter(n => n.metadata.project === options.project);
16
- }
8
+ const results = await searchHybrid({ supabase: config.supabase, openai: config.openai }, {
9
+ query,
10
+ limit: (options.type || options.project) ? 10 : 1,
11
+ document_type: options.type,
12
+ project: options.project,
13
+ });
17
14
  if (results.length === 0) {
18
- fatal('No matching notes found.', ExitCode.NOTE_NOT_FOUND);
15
+ fatal('No matching documents found.', ExitCode.DOCUMENT_NOT_FOUND);
19
16
  }
20
- const note = results[0];
21
- const upsertKey = note.metadata.upsert_key || `note-${note.id}`;
22
- const filename = `${upsertKey}.md`;
17
+ const document = results[0];
18
+ const filename = `${document.name}.md`;
23
19
  mkdirSync(VIEW_DIR, { recursive: true });
24
20
  const filePath = resolve(VIEW_DIR, filename);
25
- writeFileSync(filePath, note.content + '\n', 'utf-8');
26
- console.log(`Match: "${upsertKey}" (similarity: ${note.similarity.toFixed(3)})`);
21
+ writeFileSync(filePath, document.content + '\n', 'utf-8');
22
+ const score = document.score?.toFixed(3) ?? document.similarity?.toFixed(3) ?? 'n/a';
23
+ console.log(`Match: "${document.name}" (score: ${score})`);
27
24
  console.log(filePath);
28
25
  try {
29
26
  execFileSync('code', [filePath], { stdio: 'ignore' });
@@ -1,13 +1,14 @@
1
1
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, readdirSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import { loadConfigFile, saveConfigFile } from '../lib/config.js';
4
- import { fetchPersonaNotes, updateNoteContent, updateNoteHash, opAddNote } from '../lib/notes.js';
4
+ import { fetchSyncableNotes, updateNoteContent, updateNoteHash, opAddNote } from '../lib/notes.js';
5
5
  import { contentHash } from '../lib/hash.js';
6
6
  import { generateClaudeMd, generateMemoryMd } from '../lib/generators.js';
7
7
  import { confirm } from '../lib/prompt.js';
8
+ import { writeNoteFile } from '../lib/file-writer.js';
8
9
  export async function sync(config, options) {
9
10
  const { quiet, force, dryRun } = options;
10
- const notes = await fetchPersonaNotes(config.supabase);
11
+ const notes = await fetchSyncableNotes(config.supabase);
11
12
  const result = {
12
13
  downloaded: [],
13
14
  uploaded: [],
@@ -25,15 +26,17 @@ export async function sync(config, options) {
25
26
  await syncTypeRegistryPull(config, quiet, force, dryRun);
26
27
  const notesByFile = new Map();
27
28
  for (const note of notes) {
28
- const localFile = note.metadata.local_file;
29
- if (localFile)
30
- notesByFile.set(localFile, note);
29
+ const filePath = note.metadata.file_path;
30
+ const fileKey = filePath ? filePath.split('/').pop() : undefined;
31
+ if (fileKey)
32
+ notesByFile.set(fileKey, note);
31
33
  }
32
34
  // --- Phase 1: Process each persona note ---
33
35
  for (const note of notes) {
34
- const localFile = note.metadata.local_file;
35
- if (!localFile)
36
+ const noteFilePath = note.metadata.file_path;
37
+ if (!noteFilePath)
36
38
  continue;
39
+ const localFile = noteFilePath.split('/').pop();
37
40
  const filePath = resolve(config.memoryDir, localFile);
38
41
  const ledgerContent = note.content;
39
42
  const ledgerHash = contentHash(ledgerContent);
@@ -173,26 +176,62 @@ export async function sync(config, options) {
173
176
  console.error(` ${file} — removed (no longer in Ledger)`);
174
177
  }
175
178
  }
179
+ // --- Phase 2.5: Write file_path notes to disk (hooks, skills, configs) ---
180
+ const filePathNotes = notes.filter(n => {
181
+ const fp = n.metadata.file_path;
182
+ return fp && fp.startsWith('/') && !fp.includes('/memory/');
183
+ });
184
+ for (const note of filePathNotes) {
185
+ const fp = note.metadata.file_path;
186
+ const perms = note.metadata.file_permissions ?? null;
187
+ if (dryRun) {
188
+ if (!quiet)
189
+ console.error(` ${fp} — would write (file_path note)`);
190
+ continue;
191
+ }
192
+ const writeResult = writeNoteFile(note.content, fp, perms);
193
+ if (writeResult.status === 'written') {
194
+ if (!quiet)
195
+ console.error(` ${fp} — written`);
196
+ }
197
+ }
176
198
  // --- Phase 3: Regenerate MEMORY.md and CLAUDE.md ---
177
199
  if (!dryRun) {
178
- const allLocalFiles = [...result.downloaded, ...result.uploaded, ...result.skipped, ...result.conflicts];
200
+ const autoLoadFiles = notes
201
+ .filter(n => n.metadata.auto_load === true && n.metadata.file_path)
202
+ .map(n => {
203
+ const fp = n.metadata.file_path;
204
+ return fp.includes('/') ? fp.split('/').pop() : fp;
205
+ });
206
+ const allLocalFiles = [...new Set([...autoLoadFiles, ...result.downloaded, ...result.uploaded, ...result.skipped])];
179
207
  const memoryPath = resolve(config.memoryDir, 'MEMORY.md');
180
208
  writeFileSync(memoryPath, generateMemoryMd(allLocalFiles), 'utf-8');
181
- const feedbackNotes = notes.filter(n => n.metadata.type === 'feedback');
182
- const newClaudeMd = generateClaudeMd(feedbackNotes);
183
- if (existsSync(config.claudeMdPath)) {
184
- const existing = readFileSync(config.claudeMdPath, 'utf-8');
185
- if (existing.startsWith('# Global Rules') || force) {
209
+ // CLAUDE.md: prefer claude-md note, fall back to legacy generation
210
+ const claudeMdNote = notes.find(n => n.metadata.type === 'claude-md' ||
211
+ n.metadata.upsert_key === 'claude-md-backup');
212
+ if (claudeMdNote) {
213
+ writeFileSync(config.claudeMdPath, claudeMdNote.content, 'utf-8');
214
+ if (!quiet)
215
+ console.error(' wrote ~/CLAUDE.md (from claude-md note)');
216
+ }
217
+ else {
218
+ // Legacy fallback
219
+ const feedbackNotes = notes.filter(n => n.metadata.type === 'feedback');
220
+ const newClaudeMd = generateClaudeMd(feedbackNotes);
221
+ if (existsSync(config.claudeMdPath)) {
222
+ const existing = readFileSync(config.claudeMdPath, 'utf-8');
223
+ if (existing.startsWith('# Global Rules') || force) {
224
+ writeFileSync(config.claudeMdPath, newClaudeMd, 'utf-8');
225
+ if (!quiet)
226
+ console.error(' wrote ~/CLAUDE.md');
227
+ }
228
+ }
229
+ else {
186
230
  writeFileSync(config.claudeMdPath, newClaudeMd, 'utf-8');
187
231
  if (!quiet)
188
232
  console.error(' wrote ~/CLAUDE.md');
189
233
  }
190
234
  }
191
- else {
192
- writeFileSync(config.claudeMdPath, newClaudeMd, 'utf-8');
193
- if (!quiet)
194
- console.error(' wrote ~/CLAUDE.md');
195
- }
196
235
  }
197
236
  // --- Phase 3.5: Push type registry ---
198
237
  await syncTypeRegistryPush(config, quiet, dryRun);
@@ -225,7 +264,7 @@ export async function syncTypeRegistryPush(config, quiet, dryRun) {
225
264
  await opAddNote(clients, content, 'system-rule', 'ledger-sync', {
226
265
  upsert_key: 'system-rule-type-registry',
227
266
  description: 'User-defined type registry overrides. Managed by ledger sync.',
228
- delivery: 'persona',
267
+ domain: 'system',
229
268
  scope: 'system',
230
269
  interactive_skip: true,
231
270
  }, true); // force: true to skip duplicate guard
@@ -1,20 +1,26 @@
1
- import { opUpdateMetadata } from '../lib/notes.js';
1
+ import { getDocumentById } from '../lib/documents/fetching.js';
2
+ import { updateDocumentFields } from '../lib/documents/operations.js';
2
3
  export async function tag(config, id, options) {
3
- const metadata = {};
4
- if (options.description)
5
- metadata.description = options.description;
6
- if (options.project)
7
- metadata.project = options.project;
8
- if (options.scope)
9
- metadata.scope = options.scope;
10
- if (Object.keys(metadata).length === 0) {
11
- console.error('No metadata fields provided. Use --description, --project, or --scope.');
4
+ if (!options.description && !options.project && !options.domain && !options.status) {
5
+ console.error('No fields provided. Use --description, --project, --domain, or --status.');
12
6
  process.exit(1);
13
7
  }
14
- const result = await opUpdateMetadata({ supabase: config.supabase, openai: config.openai }, id, metadata);
15
- if (result.status === 'error') {
16
- console.error(result.message);
8
+ const document = await getDocumentById(config.supabase, id);
9
+ if (!document) {
10
+ console.error(`Document ${id} not found.`);
17
11
  process.exit(1);
18
12
  }
19
- console.error(result.message);
13
+ if (document.protection === 'immutable') {
14
+ console.error(`Document "${document.name}" (id: ${id}) is immutable and cannot be updated.`);
15
+ process.exit(1);
16
+ }
17
+ await updateDocumentFields({ supabase: config.supabase, openai: config.openai }, {
18
+ id,
19
+ description: options.description,
20
+ project: options.project,
21
+ domain: options.domain,
22
+ status: options.status,
23
+ agent: 'cli',
24
+ });
25
+ console.error(`Document ${id} fields updated.`);
20
26
  }
@@ -1,22 +1,54 @@
1
- import { opUpdateNote } from '../lib/notes.js';
1
+ import { resolve } from 'path';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { getDocumentById } from '../lib/documents/fetching.js';
4
+ import { updateDocumentFromFile, VerifyMismatchError } from '../lib/documents/operations.js';
2
5
  import { confirm } from '../lib/prompt.js';
3
- export async function update(config, id, content, options) {
4
- const clients = { supabase: config.supabase, openai: config.openai };
5
- // First call: show confirmation
6
- const preview = await opUpdateNote(clients, id, content, options.metadata, false);
7
- if (preview.status === 'error') {
8
- console.error(preview.message);
9
- process.exit(1);
6
+ import { fatal, ExitCode } from '../lib/errors.js';
7
+ /**
8
+ * Update an existing document by reading new content from a file on disk.
9
+ * Auto-verified after push: the doc is pulled back and byte-compared against
10
+ * the file we sent. VerifyMismatchError on round-trip drift.
11
+ *
12
+ * Bytes flow file -> updateDocumentFromFile() -> Postgres without retyping.
13
+ * The composed-string path (`-c`) was removed in Phase 4 of the
14
+ * file-based-write-api rollout to close the drift class of bug.
15
+ */
16
+ export async function updateFromFile(config, id, filePath, options = {}) {
17
+ const absPath = resolve(filePath);
18
+ if (!existsSync(absPath)) {
19
+ fatal(`File not found: ${absPath}`, ExitCode.FILE_NOT_FOUND);
10
20
  }
11
- console.error(preview.message);
12
- const proceed = await confirm('\nProceed with update?');
13
- if (!proceed) {
14
- console.error('Cancelled.');
15
- return;
21
+ const document = await getDocumentById(config.supabase, id);
22
+ if (!document) {
23
+ fatal(`Document ${id} not found.`, ExitCode.DOCUMENT_NOT_FOUND);
24
+ }
25
+ if (document.protection === 'immutable') {
26
+ fatal(`Document "${document.name}" (id: ${id}) is immutable and cannot be updated.`, ExitCode.PROTECTED);
27
+ }
28
+ const newContent = readFileSync(absPath, 'utf8');
29
+ process.stderr.write(`Document: "${document.name}" (id: ${id})\n`);
30
+ process.stderr.write(`Current content preview: ${document.content.slice(0, 200)}${document.content.length > 200 ? '...' : ''}\n`);
31
+ process.stderr.write(`\nNew content preview: ${newContent.slice(0, 200)}${newContent.length > 200 ? '...' : ''}\n`);
32
+ process.stderr.write(`Source file: ${absPath} (${newContent.length} bytes)\n`);
33
+ if (!options.yes) {
34
+ const proceed = await confirm('\nProceed with update?');
35
+ if (!proceed) {
36
+ process.stderr.write('Cancelled.\n');
37
+ return;
38
+ }
39
+ }
40
+ try {
41
+ const result = await updateDocumentFromFile({ supabase: config.supabase, openai: config.openai }, { id, filePath: absPath, agent: 'cli' });
42
+ process.stderr.write(`Document ${id} updated successfully (${result.bytes} bytes, verified).\n`);
43
+ }
44
+ catch (error) {
45
+ if (error instanceof VerifyMismatchError) {
46
+ process.stderr.write(`\nVerify failed on document ${error.id}.\n`);
47
+ process.stderr.write(`Pushed ${error.expectedLength} bytes, pulled ${error.actualLength} bytes.\n`);
48
+ process.stderr.write(`${error.diffPreview}\n`);
49
+ process.stderr.write(`The push completed but the round-trip diff caught drift. Re-pull and re-edit.\n`);
50
+ process.exit(ExitCode.VERIFY_MISMATCH);
51
+ }
52
+ throw error;
16
53
  }
17
- // Second call: execute
18
- const result = await opUpdateNote(clients, id, content, options.metadata, true);
19
- console.error(result.message);
20
- if (result.status === 'error')
21
- process.exit(1);
22
54
  }
@@ -107,7 +107,7 @@ export async function wizard() {
107
107
  // Step 7: Migrate local files
108
108
  const unknownFiles = getMemoryFiles(config);
109
109
  const personaNotes = await fetchPersonaNotes(config.supabase);
110
- const knownFiles = new Set(personaNotes.map(n => n.metadata.local_file).filter(Boolean));
110
+ const knownFiles = new Set(personaNotes.map(n => n.metadata.file_path).filter(Boolean));
111
111
  const unknowns = unknownFiles.filter(f => !knownFiles.has(f));
112
112
  if (unknowns.length === 0) {
113
113
  console.error('Step 7: Migration: no unknown files\n');
@@ -177,7 +177,7 @@ async function showAlreadySetUp(checks) {
177
177
  const { data: personaData } = await supabase
178
178
  .from('notes')
179
179
  .select('id')
180
- .eq('metadata->>delivery', 'persona')
180
+ .eq('metadata->>domain', 'persona')
181
181
  .limit(1);
182
182
  const hasPersona = personaData !== null && personaData.length > 0;
183
183
  // Check platforms
@@ -318,7 +318,7 @@ async function stepDeviceAlias(config) {
318
318
  content,
319
319
  metadata: {
320
320
  type: 'reference',
321
- delivery: 'knowledge',
321
+ domain: 'workspace',
322
322
  agent: 'ledger-wizard',
323
323
  scope: 'user',
324
324
  upsert_key: 'user-devices',
@@ -0,0 +1,163 @@
1
+ // ai-search.ts
2
+ // AI-powered search — vector (meaning), keyword (exact words), hybrid (both combined).
3
+ // Each function calls a Postgres RPC function that does the actual search.
4
+ // TypeScript's job: generate the query embedding, then call the right function.
5
+ import { getOrCacheQueryEmbedding, toVectorString } from './embeddings.js';
6
+ // =============================================================================
7
+ // Search evaluation logging
8
+ // =============================================================================
9
+ /**
10
+ * Log a search to the search_evaluations table.
11
+ * Called after every search — silently records what was searched,
12
+ * what came back, and how long it took. This is the raw data
13
+ * that powers all evaluation, quality tracking, and improvement.
14
+ *
15
+ * Fire-and-forget: we don't await this. If logging fails,
16
+ * the search still returns results. The user never waits for logging.
17
+ */
18
+ function logSearchEvaluation(supabase, params) {
19
+ // Extract unique document_types and source_types from results
20
+ // These tell us which types of documents search finds well vs poorly
21
+ const documentTypes = [...new Set(params.results.map(result => result.document_type))];
22
+ // Build the results JSONB array — just IDs and scores, not full content
23
+ const resultsSummary = params.results.map(result => ({
24
+ id: result.id,
25
+ score: result.similarity ?? result.rank ?? result.score ?? null,
26
+ document_type: result.document_type,
27
+ }));
28
+ // Fire and forget — don't await, don't block the search response
29
+ supabase
30
+ .from('search_evaluations')
31
+ .insert({
32
+ query_text: params.query,
33
+ search_mode: params.searchMode,
34
+ result_count: params.results.length,
35
+ results: resultsSummary,
36
+ document_types: documentTypes,
37
+ response_time_ms: params.responseTimeMs,
38
+ })
39
+ .then(() => { })
40
+ .catch(() => {
41
+ // Silently ignore logging failures — search results matter more
42
+ });
43
+ }
44
+ // =============================================================================
45
+ // Search functions
46
+ // =============================================================================
47
+ /**
48
+ * Search by meaning — "how does auth work?" finds documents about OAuth.
49
+ *
50
+ * Flow:
51
+ * 1. Convert query text to an embedding (array of 1,536 numbers) via OpenAI
52
+ * 2. Check the query_cache first to avoid repeat API calls
53
+ * 3. Call match_documents RPC — Postgres compares the query embedding
54
+ * against every chunk's embedding using cosine similarity
55
+ * 4. Return matching documents sorted by similarity
56
+ */
57
+ export async function searchByVector(clients, props) {
58
+ const startTime = Date.now();
59
+ const queryEmbedding = await getOrCacheQueryEmbedding(clients, props.query);
60
+ const { data, error } = await clients.supabase.rpc('match_documents', {
61
+ q_emb: toVectorString(queryEmbedding),
62
+ p_threshold: props.threshold ?? 0.25,
63
+ p_max_results: props.limit ?? 10,
64
+ p_domain: props.domain ?? null,
65
+ p_document_type: props.document_type ?? null,
66
+ p_project: props.project ?? null,
67
+ });
68
+ if (error)
69
+ throw new Error(`Vector search failed: ${error.message}`);
70
+ const results = (data ?? []);
71
+ logSearchEvaluation(clients.supabase, {
72
+ query: props.query,
73
+ searchMode: 'vector',
74
+ results,
75
+ responseTimeMs: Date.now() - startTime,
76
+ });
77
+ return results;
78
+ }
79
+ /**
80
+ * Search by exact words — "pgvector HNSW" finds documents containing those words.
81
+ *
82
+ * No embedding needed — Postgres uses the search_vector column (GIN index)
83
+ * to match words directly. Good for code identifiers, proper nouns, error messages.
84
+ */
85
+ export async function searchByKeyword(supabase, props) {
86
+ const startTime = Date.now();
87
+ const { data, error } = await supabase.rpc('match_documents_keyword', {
88
+ p_query: props.query,
89
+ p_max_results: props.limit ?? 10,
90
+ p_domain: props.domain ?? null,
91
+ p_document_type: props.document_type ?? null,
92
+ p_project: props.project ?? null,
93
+ });
94
+ if (error)
95
+ throw new Error(`Keyword search failed: ${error.message}`);
96
+ const results = (data ?? []);
97
+ logSearchEvaluation(supabase, {
98
+ query: props.query,
99
+ searchMode: 'keyword',
100
+ results,
101
+ responseTimeMs: Date.now() - startTime,
102
+ });
103
+ return results;
104
+ }
105
+ /**
106
+ * Combined search — runs both vector AND keyword, merges results with RRF fusion.
107
+ *
108
+ * Documents found by both methods rank highest. This is the default search mode
109
+ * because it handles both meaning-based queries ("how does auth work?") and
110
+ * exact-term queries ("pgvector HNSW") well.
111
+ *
112
+ * RRF (Reciprocal Rank Fusion) formula:
113
+ * score = 1/(k + vector_rank) + 1/(k + keyword_rank)
114
+ * k=60 is a smoothing constant that prevents the #1 result from dominating.
115
+ */
116
+ export async function searchHybrid(clients, props) {
117
+ const startTime = Date.now();
118
+ const queryEmbedding = await getOrCacheQueryEmbedding(clients, props.query);
119
+ const { data, error } = await clients.supabase.rpc('match_documents_hybrid', {
120
+ q_emb: toVectorString(queryEmbedding),
121
+ q_text: props.query,
122
+ p_threshold: props.threshold ?? 0.25,
123
+ p_max_results: props.limit ?? 10,
124
+ p_domain: props.domain ?? null,
125
+ p_document_type: props.document_type ?? null,
126
+ p_project: props.project ?? null,
127
+ p_rrf_k: props.rrf_k ?? 60,
128
+ });
129
+ if (error)
130
+ throw new Error(`Hybrid search failed: ${error.message}`);
131
+ const results = (data ?? []);
132
+ logSearchEvaluation(clients.supabase, {
133
+ query: props.query,
134
+ searchMode: 'hybrid',
135
+ results,
136
+ responseTimeMs: Date.now() - startTime,
137
+ });
138
+ return results;
139
+ }
140
+ /**
141
+ * Smart retrieval — decide how much content to send to the LLM.
142
+ *
143
+ * After search finds a matching document, this decides:
144
+ * - Small document (under context_window chars) → return full content
145
+ * - Large document → return only the matched chunk + neighbors
146
+ *
147
+ * Why: sending a 50,000-char document to the LLM when only one section
148
+ * is relevant wastes tokens and money. But sending only a 500-char chunk
149
+ * might miss context. This finds the balance.
150
+ */
151
+ export async function retrieveContext(supabase, props) {
152
+ const { data, error } = await supabase.rpc('retrieve_context', {
153
+ p_document_id: props.document_id,
154
+ p_matched_chunk_index: props.matched_chunk_index,
155
+ p_context_window: props.context_window ?? 4000,
156
+ p_neighbor_count: props.neighbor_count ?? 1,
157
+ });
158
+ if (error)
159
+ throw new Error(`Context retrieval failed: ${error.message}`);
160
+ if (!data || (Array.isArray(data) && data.length === 0))
161
+ return null;
162
+ return (Array.isArray(data) ? data[0] : data);
163
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Log a note operation to the audit_log table.
3
+ * Extracts domain + agent from metadata automatically.
4
+ * Silently fails if the audit_log table doesn't exist yet (pre-migration).
5
+ */
6
+ export async function audit(clients, noteId, metadata, operation, diff) {
7
+ const { error } = await clients.supabase
8
+ .from('audit_log')
9
+ .insert({
10
+ note_id: noteId,
11
+ domain: metadata.domain ?? null,
12
+ operation,
13
+ agent: metadata.agent ?? 'unknown',
14
+ diff,
15
+ });
16
+ if (error) {
17
+ console.error(`[audit] Warning: failed to write audit entry: ${error.message}`);
18
+ }
19
+ }
@@ -0,0 +1,60 @@
1
+ import { homedir } from 'os';
2
+ import { resolve } from 'path';
3
+ import { TYPE_MIGRATION, inferDomain, getProtectionDefault, getAutoLoadDefault, isV2Type, } from './domains.js';
4
+ const HOME_PROJECT_DIR = homedir().replace(/\//g, '-');
5
+ const MEMORY_DIR = resolve(homedir(), `.claude/projects/${HOME_PROJECT_DIR}/memory`);
6
+ /**
7
+ * Backfill v1 note metadata to v2 format.
8
+ * Pure function — no DB calls. Idempotent: skips notes that already have `domain`.
9
+ */
10
+ export function backfillMetadata(metadata) {
11
+ // Idempotent: if already has domain + schema_version, skip
12
+ if (metadata.domain && metadata.schema_version) {
13
+ return metadata;
14
+ }
15
+ const result = { ...metadata };
16
+ const oldType = metadata.type;
17
+ // --- Step 1: Migrate type + infer domain ---
18
+ let newType = oldType ?? 'knowledge';
19
+ let domain;
20
+ const migration = oldType ? TYPE_MIGRATION[oldType] : undefined;
21
+ const hasProject = !!metadata.project;
22
+ if (migration) {
23
+ newType = migration.type;
24
+ domain = migration.domain;
25
+ // knowledge-guide maps to general by default, but promote to project if note has a project tag
26
+ if (migration.domain === 'general' && hasProject) {
27
+ domain = 'project';
28
+ }
29
+ }
30
+ else if (oldType === 'general') {
31
+ newType = 'general';
32
+ domain = 'general';
33
+ }
34
+ else if (oldType && isV2Type(oldType)) {
35
+ domain = inferDomain(oldType);
36
+ }
37
+ else {
38
+ newType = 'general';
39
+ domain = 'general';
40
+ }
41
+ result.type = newType;
42
+ result.domain = domain;
43
+ // --- Step 2: Set protection and auto_load from defaults ---
44
+ result.protection = getProtectionDefault(newType);
45
+ result.auto_load = getAutoLoadDefault(domain, newType);
46
+ // --- Step 3: Ownership (single user for now) ---
47
+ result.owner_type = 'user';
48
+ result.owner_id = null;
49
+ // --- Step 4: Schema + embedding tracking ---
50
+ result.schema_version = 1;
51
+ result.embedding_model = 'openai/text-embedding-3-small';
52
+ result.embedding_dimensions = 1536;
53
+ // --- Step 5: Derive file_path from local_file for persona notes ---
54
+ const localFile = metadata.local_file;
55
+ if (localFile && domain === 'persona' && !result.file_path) {
56
+ result.file_path = resolve(MEMORY_DIR, localFile);
57
+ result.file_permissions = '644';
58
+ }
59
+ return result;
60
+ }