@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.
- package/dist/cli.js +177 -221
- package/dist/commands/add.js +51 -100
- package/dist/commands/backfill.js +55 -0
- package/dist/commands/backup.js +10 -10
- package/dist/commands/check.js +21 -29
- package/dist/commands/config.js +13 -12
- package/dist/commands/delete.js +22 -17
- package/dist/commands/eval-judge.js +11 -0
- package/dist/commands/eval.js +321 -0
- package/dist/commands/export.js +8 -10
- package/dist/commands/get.js +9 -0
- package/dist/commands/hunt.js +206 -0
- package/dist/commands/ingest.js +15 -14
- package/dist/commands/init.js +18 -20
- package/dist/commands/list.js +21 -7
- package/dist/commands/migrate.js +11 -11
- package/dist/commands/onboard.js +2 -2
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +8 -8
- package/dist/commands/restore.js +38 -38
- package/dist/commands/show.js +13 -16
- package/dist/commands/sync.js +58 -19
- package/dist/commands/tag.js +20 -14
- package/dist/commands/update.js +50 -18
- package/dist/commands/wizard.js +3 -3
- package/dist/lib/ai-search.js +163 -0
- package/dist/lib/audit.js +19 -0
- package/dist/lib/backfill.js +60 -0
- package/dist/lib/config.js +19 -2
- package/dist/lib/document-classification.js +5 -0
- package/dist/lib/document-fetching.js +77 -0
- package/dist/lib/document-operations.js +150 -0
- package/dist/lib/documents/classification.js +5 -0
- package/dist/lib/documents/fetching.js +89 -0
- package/dist/lib/documents/operations.js +304 -0
- package/dist/lib/domains.js +116 -0
- package/dist/lib/embeddings.js +190 -0
- package/dist/lib/errors.js +3 -1
- package/dist/lib/eval/eval-advanced.js +289 -0
- package/dist/lib/eval/eval-judge-session.js +233 -0
- package/dist/lib/eval/eval-store.js +105 -0
- package/dist/lib/eval/eval.js +303 -0
- package/dist/lib/file-writer.js +23 -0
- package/dist/lib/generators.js +44 -45
- package/dist/lib/hunter-db.js +235 -0
- package/dist/lib/hunter-rss.js +30 -0
- package/dist/lib/hunter-scoring.js +55 -0
- package/dist/lib/hunter-types.js +36 -0
- package/dist/lib/lint-configs.js +20 -0
- package/dist/lib/migrate.js +2 -2
- package/dist/lib/notes.js +173 -59
- package/dist/lib/observability.js +296 -0
- package/dist/lib/op-add-note-types.test.js +7 -6
- package/dist/lib/prompt.js +8 -8
- package/dist/lib/rate-limiter.js +103 -0
- package/dist/lib/search/ai-search.js +396 -0
- package/dist/lib/search/chunk-context-enrichment.js +155 -0
- package/dist/lib/search/embeddings.js +293 -0
- package/dist/lib/search/reranker.js +120 -0
- package/dist/lib/search/semantic-cache.js +53 -0
- package/dist/lib/type-registry.test.js +6 -6
- package/dist/mcp-server.js +553 -66
- package/dist/migrations/migrations/005-audit-log.sql +22 -0
- package/dist/migrations/migrations/005_opportunities.sql +48 -0
- package/dist/migrations/migrations/006-audited-operations.sql +235 -0
- package/dist/migrations/migrations/006_hunt_analytics.sql +38 -0
- package/dist/migrations/migrations/007-eval-golden-judgments.sql +119 -0
- package/dist/migrations/migrations/008-drop-expected-doc-ids.sql +9 -0
- package/dist/migrations/migrations/008-judge-helpers.sql +21 -0
- package/dist/migrations/migrations/009-semantic-cache.sql +216 -0
- package/dist/scripts/batch-grade.js +344 -0
- package/dist/scripts/benchmark-ingestion.js +376 -0
- package/dist/scripts/convert-judgments-to-graded.js +88 -0
- package/dist/scripts/diagnose-first-result.js +333 -0
- package/dist/scripts/drop-golden-query.js +53 -0
- package/dist/scripts/eval-search.js +115 -0
- package/dist/scripts/grade-unjudged-top1.js +138 -0
- package/dist/scripts/hunter-analytics.js +38 -0
- package/dist/scripts/hunter-cron.js +63 -0
- package/dist/scripts/hunter-purge.js +25 -0
- package/dist/scripts/migrate-v2.js +140 -0
- package/dist/scripts/reindex.js +74 -0
- package/dist/scripts/sync-local-docs.js +153 -0
- package/package.json +7 -1
package/dist/commands/restore.js
CHANGED
|
@@ -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
|
|
11
|
+
let documents;
|
|
11
12
|
try {
|
|
12
|
-
|
|
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 ${
|
|
19
|
+
console.error(`Found ${documents.length} documents in backup.`);
|
|
19
20
|
// Check current database
|
|
20
21
|
const { count } = await config.supabase
|
|
21
|
-
.from('
|
|
22
|
+
.from('documents')
|
|
22
23
|
.select('*', { count: 'exact', head: true });
|
|
23
24
|
if (count && count > 0) {
|
|
24
|
-
console.error(`Database already has ${count}
|
|
25
|
-
const proceed = await confirm('Restore will add
|
|
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
|
|
35
|
-
// Check for existing
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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)`);
|
package/dist/commands/show.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
15
|
+
fatal('No matching documents found.', ExitCode.DOCUMENT_NOT_FOUND);
|
|
19
16
|
}
|
|
20
|
-
const
|
|
21
|
-
const
|
|
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,
|
|
26
|
-
|
|
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' });
|
package/dist/commands/sync.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
35
|
-
if (!
|
|
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
|
|
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
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
267
|
+
domain: 'system',
|
|
229
268
|
scope: 'system',
|
|
230
269
|
interactive_skip: true,
|
|
231
270
|
}, true); // force: true to skip duplicate guard
|
package/dist/commands/tag.js
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
15
|
-
if (
|
|
16
|
-
console.error(
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -1,22 +1,54 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
}
|
package/dist/commands/wizard.js
CHANGED
|
@@ -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.
|
|
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->>
|
|
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
|
-
|
|
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
|
+
}
|