plasalid 0.7.1 → 0.7.3
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/README.md +15 -15
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +1 -1
- package/dist/ai/agent.d.ts +9 -10
- package/dist/ai/agent.js +31 -15
- package/dist/ai/personas.d.ts +1 -1
- package/dist/ai/personas.js +57 -55
- package/dist/ai/prompt-sections.d.ts +4 -4
- package/dist/ai/prompt-sections.js +1 -1
- package/dist/ai/system-prompt.d.ts +2 -2
- package/dist/ai/system-prompt.js +5 -5
- package/dist/ai/tools/account-mutex.d.ts +1 -0
- package/dist/ai/tools/account-mutex.js +16 -0
- package/dist/ai/tools/clarify.d.ts +2 -0
- package/dist/ai/tools/clarify.js +169 -0
- package/dist/ai/tools/index.js +10 -18
- package/dist/ai/tools/ingest.d.ts +2 -2
- package/dist/ai/tools/ingest.js +284 -244
- package/dist/ai/tools/merchants.js +1 -28
- package/dist/ai/tools/read.js +8 -8
- package/dist/ai/tools/record.js +7 -40
- package/dist/ai/tools/resolve.js +25 -22
- package/dist/ai/tools/scan.js +0 -1
- package/dist/ai/tools/types.d.ts +14 -21
- package/dist/cli/commands/clarify.d.ts +5 -0
- package/dist/cli/commands/clarify.js +44 -0
- package/dist/cli/commands/record.js +1 -82
- package/dist/cli/commands/resolve.d.ts +5 -2
- package/dist/cli/commands/resolve.js +36 -5
- package/dist/cli/commands/revert.js +4 -2
- package/dist/cli/commands/rules.js +2 -2
- package/dist/cli/commands/scan.js +199 -128
- package/dist/cli/commands/status.js +6 -6
- package/dist/cli/index.js +8 -29
- package/dist/cli/ink/ScanDashboard.d.ts +49 -0
- package/dist/cli/ink/ScanDashboard.js +214 -0
- package/dist/cli/ink/scan_dashboard.d.ts +40 -25
- package/dist/cli/ink/scan_dashboard.js +139 -44
- package/dist/cli/setup.js +1 -1
- package/dist/cli/ux.js +1 -1
- package/dist/db/queries/account-balance.d.ts +1 -1
- package/dist/db/queries/questions.d.ts +62 -0
- package/dist/db/queries/questions.js +110 -0
- package/dist/db/queries/transactions.d.ts +1 -1
- package/dist/db/queries/unknowns.d.ts +17 -15
- package/dist/db/queries/unknowns.js +35 -39
- package/dist/db/schema.js +6 -28
- package/dist/scanner/audit/auditor.d.ts +31 -0
- package/dist/scanner/audit/auditor.js +72 -0
- package/dist/scanner/audit/engine.d.ts +10 -0
- package/dist/scanner/audit/engine.js +98 -0
- package/dist/scanner/audit/eventBus.d.ts +60 -0
- package/dist/scanner/audit/eventBus.js +35 -0
- package/dist/scanner/audit/passes/index.d.ts +11 -0
- package/dist/scanner/audit/passes/index.js +9 -0
- package/dist/scanner/audit/passes/types.d.ts +23 -0
- package/dist/scanner/audit/passes/types.js +1 -0
- package/dist/scanner/audit/types.d.ts +27 -0
- package/dist/scanner/audit/types.js +1 -0
- package/dist/scanner/auditor.d.ts +51 -0
- package/dist/scanner/auditor.js +80 -0
- package/dist/scanner/buffer/engine.d.ts +9 -0
- package/dist/scanner/buffer/engine.js +110 -0
- package/dist/scanner/buffer/sharedBuffer.d.ts +78 -0
- package/dist/scanner/buffer/sharedBuffer.js +130 -0
- package/dist/scanner/buffer/types.d.ts +67 -0
- package/dist/scanner/buffer/types.js +1 -0
- package/dist/scanner/buffer.d.ts +45 -38
- package/dist/scanner/buffer.js +93 -61
- package/dist/scanner/bus/engine.d.ts +11 -0
- package/dist/scanner/bus/engine.js +42 -0
- package/dist/scanner/bus/types.d.ts +53 -0
- package/dist/scanner/bus/types.js +1 -0
- package/dist/scanner/bus.d.ts +38 -0
- package/dist/scanner/bus.js +37 -0
- package/dist/scanner/chunk-worker.d.ts +19 -0
- package/dist/scanner/chunk-worker.js +67 -0
- package/dist/scanner/chunkWorker.d.ts +20 -0
- package/dist/scanner/chunkWorker.js +59 -0
- package/dist/scanner/chunker/chunker.d.ts +7 -0
- package/dist/scanner/chunker/chunker.js +60 -0
- package/dist/scanner/chunker.d.ts +7 -0
- package/dist/scanner/chunker.js +60 -0
- package/dist/scanner/clarifier-memory.d.ts +8 -0
- package/dist/scanner/clarifier-memory.js +24 -0
- package/dist/scanner/clarifier.d.ts +39 -0
- package/dist/scanner/clarifier.js +196 -0
- package/dist/scanner/converge.d.ts +29 -0
- package/dist/scanner/converge.js +15 -0
- package/dist/scanner/decrypt.d.ts +10 -0
- package/dist/scanner/decrypt.js +80 -0
- package/dist/scanner/engine/scanEngine.d.ts +24 -0
- package/dist/scanner/engine/scanEngine.js +87 -0
- package/dist/scanner/engine/types.d.ts +90 -0
- package/dist/scanner/engine/types.js +1 -0
- package/dist/scanner/engine.d.ts +90 -0
- package/dist/scanner/engine.js +84 -0
- package/dist/scanner/file-worker.d.ts +33 -0
- package/dist/scanner/file-worker.js +28 -0
- package/dist/scanner/fileWorker.d.ts +33 -0
- package/dist/scanner/fileWorker.js +22 -0
- package/dist/scanner/hooks/types.d.ts +25 -0
- package/dist/scanner/hooks/types.js +1 -0
- package/dist/scanner/hooks.d.ts +23 -0
- package/dist/scanner/hooks.js +1 -0
- package/dist/scanner/parse.d.ts +10 -0
- package/dist/scanner/parse.js +47 -0
- package/dist/scanner/passes/index.d.ts +8 -0
- package/dist/scanner/passes/index.js +6 -0
- package/dist/scanner/passes/types.d.ts +22 -0
- package/dist/scanner/passes/types.js +1 -0
- package/dist/scanner/pdf/chunker.d.ts +7 -0
- package/dist/scanner/pdf/chunker.js +60 -0
- package/dist/scanner/pdf/password-store.d.ts +34 -0
- package/dist/scanner/pdf/password-store.js +83 -0
- package/dist/scanner/pdf/pdf-unlock.d.ts +17 -0
- package/dist/scanner/pdf/pdf-unlock.js +50 -0
- package/dist/scanner/pdf/pdf.d.ts +17 -0
- package/dist/scanner/pdf/pdf.js +36 -0
- package/dist/scanner/pdf/state-machine.d.ts +60 -0
- package/dist/scanner/pdf/state-machine.js +64 -0
- package/dist/scanner/pdf/unlock.d.ts +22 -0
- package/dist/scanner/pdf/unlock.js +121 -0
- package/dist/scanner/phase-decrypt.d.ts +10 -0
- package/dist/scanner/phase-decrypt.js +80 -0
- package/dist/scanner/phase-parse.d.ts +10 -0
- package/dist/scanner/phase-parse.js +46 -0
- package/dist/scanner/phases/chunk.d.ts +8 -0
- package/dist/scanner/phases/chunk.js +13 -0
- package/dist/scanner/phases/commit.d.ts +12 -0
- package/dist/scanner/phases/commit.js +140 -0
- package/dist/scanner/phases/decrypt.d.ts +10 -0
- package/dist/scanner/phases/decrypt.js +80 -0
- package/dist/scanner/phases/parse.d.ts +10 -0
- package/dist/scanner/phases/parse.js +46 -0
- package/dist/scanner/phases/resolve.d.ts +10 -0
- package/dist/scanner/phases/resolve.js +17 -0
- package/dist/scanner/phases/review.d.ts +10 -0
- package/dist/scanner/phases/review.js +12 -0
- package/dist/scanner/progress.d.ts +14 -0
- package/dist/scanner/progress.js +21 -0
- package/dist/scanner/resolver-memory.d.ts +8 -0
- package/dist/scanner/resolver-memory.js +24 -0
- package/dist/scanner/resolver.d.ts +39 -0
- package/dist/scanner/resolver.js +196 -0
- package/dist/scanner/result.d.ts +17 -0
- package/dist/scanner/result.js +19 -0
- package/dist/scanner/run-passes.d.ts +30 -0
- package/dist/scanner/run-passes.js +15 -0
- package/dist/scanner/unlock.js +1 -1
- package/dist/scanner/worker.d.ts +19 -0
- package/dist/scanner/worker.js +67 -0
- package/dist/scanner/workers/chunkWorker.d.ts +20 -0
- package/dist/scanner/workers/chunkWorker.js +65 -0
- package/dist/scanner/workers/fileWorker.d.ts +32 -0
- package/dist/scanner/workers/fileWorker.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { validateTransaction, insertTransactionRows, } from "../../db/queries/transactions.js";
|
|
3
|
+
import { recordUnknown } from "../../db/queries/unknowns.js";
|
|
4
|
+
import { appendAction } from "../../db/queries/action-log.js";
|
|
5
|
+
import { tryExecute } from "../result.js";
|
|
6
|
+
const SERIALIZE = {
|
|
7
|
+
record_transaction: (t) => ({
|
|
8
|
+
transaction: {
|
|
9
|
+
date: t.date,
|
|
10
|
+
description: t.description,
|
|
11
|
+
source_page: t.source_page ?? null,
|
|
12
|
+
raw_descriptor: t.raw_descriptor ?? null,
|
|
13
|
+
},
|
|
14
|
+
postings: t.postings,
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Commit ONE buffered transaction in its own DB transaction so a FK violation
|
|
19
|
+
* drops only this row. The audit_log row pairs with the insert so revert can
|
|
20
|
+
* undo this exact entry.
|
|
21
|
+
*/
|
|
22
|
+
async function commitOneTransaction(db, scanId, bt, fileIdByChunkId) {
|
|
23
|
+
const sourceFileId = fileIdByChunkId.get(bt.chunkId);
|
|
24
|
+
const result = await tryExecute(() => {
|
|
25
|
+
const validated = validateTransaction({
|
|
26
|
+
...bt.input,
|
|
27
|
+
id: bt.transaction_id,
|
|
28
|
+
source_file_id: sourceFileId,
|
|
29
|
+
});
|
|
30
|
+
db.transaction(() => {
|
|
31
|
+
insertTransactionRows(db, validated);
|
|
32
|
+
appendAction(db, {
|
|
33
|
+
correlation_id: scanId,
|
|
34
|
+
command: "scan",
|
|
35
|
+
action_type: "record_transaction",
|
|
36
|
+
target_id: validated.id,
|
|
37
|
+
payload: SERIALIZE.record_transaction(validated),
|
|
38
|
+
});
|
|
39
|
+
})();
|
|
40
|
+
return validated.id;
|
|
41
|
+
});
|
|
42
|
+
if (result.ok)
|
|
43
|
+
return { kind: "ok", transactionId: result.value };
|
|
44
|
+
return {
|
|
45
|
+
kind: "failed",
|
|
46
|
+
description: bt.input.description,
|
|
47
|
+
date: bt.input.date,
|
|
48
|
+
error: result.error,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function bootstrapScannedFiles(db, decrypted) {
|
|
52
|
+
const ids = [];
|
|
53
|
+
const fileIdByPath = new Map();
|
|
54
|
+
for (const file of decrypted) {
|
|
55
|
+
if (file.replacesPriorScannedFileId) {
|
|
56
|
+
db.prepare(`DELETE FROM scanned_files WHERE id = ?`).run(file.replacesPriorScannedFileId);
|
|
57
|
+
}
|
|
58
|
+
const sfId = `sf:${randomUUID()}`;
|
|
59
|
+
db.prepare(`INSERT INTO scanned_files (id, path, file_hash, mime, status) VALUES (?, ?, ?, ?, 'pending')`).run(sfId, file.path, file.hash, file.mime);
|
|
60
|
+
ids.push(sfId);
|
|
61
|
+
fileIdByPath.set(file.path, sfId);
|
|
62
|
+
}
|
|
63
|
+
return { ids, fileIdByPath };
|
|
64
|
+
}
|
|
65
|
+
function buildChunkLookup(chunks, fileIdByPath) {
|
|
66
|
+
const out = new Map();
|
|
67
|
+
for (const c of chunks) {
|
|
68
|
+
const sfId = fileIdByPath.get(c.fileId);
|
|
69
|
+
if (sfId)
|
|
70
|
+
out.set(c.chunkId, sfId);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function failuresToUnknowns(failures) {
|
|
75
|
+
return failures.map(f => ({
|
|
76
|
+
unknown_id: `bu:${randomUUID()}`,
|
|
77
|
+
chunkId: null,
|
|
78
|
+
transaction_id: null,
|
|
79
|
+
account_id: null,
|
|
80
|
+
kind: "scan_commit_failure",
|
|
81
|
+
prompt: `Could not record "${f.description}" on ${f.date}: ${f.error}. Review the source statement and re-enter via the record flow.`,
|
|
82
|
+
answer: null,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
function writeUnknowns(db, unknowns, fileIdByChunkId) {
|
|
86
|
+
const op = db.transaction(() => {
|
|
87
|
+
for (const u of unknowns) {
|
|
88
|
+
// Closed unknowns: their resolution side-effects already landed via other
|
|
89
|
+
// buffer mutations (update_posting / delete_transaction / etc.) during
|
|
90
|
+
// the audit pass. Skip persisting — leaving them out keeps the DB free
|
|
91
|
+
// of orphan rows masquerading as open work.
|
|
92
|
+
if (u.answer !== null)
|
|
93
|
+
continue;
|
|
94
|
+
const sfId = u.chunkId ? fileIdByChunkId.get(u.chunkId) ?? null : null;
|
|
95
|
+
recordUnknown(db, {
|
|
96
|
+
file_id: sfId,
|
|
97
|
+
transaction_id: u.transaction_id,
|
|
98
|
+
account_id: u.account_id,
|
|
99
|
+
kind: u.kind ?? null,
|
|
100
|
+
prompt: u.prompt,
|
|
101
|
+
options: u.options,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
op();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Phase 5 — flush the shared buffer to the DB. Per-row transactions so one
|
|
109
|
+
* bad row drops only itself (lands as a scan_commit_failure unknown). Every
|
|
110
|
+
* successful mutation appends an action_log row keyed to scanId so the run
|
|
111
|
+
* can be reverted as a unit.
|
|
112
|
+
*/
|
|
113
|
+
export async function commitPhase(db, state, hooks) {
|
|
114
|
+
if (state.review !== "commit")
|
|
115
|
+
return;
|
|
116
|
+
await hooks.beforeCommit?.(state);
|
|
117
|
+
const snapshot = state.buffer.snapshot();
|
|
118
|
+
const { ids: scannedFileIds, fileIdByPath } = bootstrapScannedFiles(db, state.decrypted);
|
|
119
|
+
const fileIdByChunkId = buildChunkLookup(state.chunks, fileIdByPath);
|
|
120
|
+
const outcomes = await Promise.all(snapshot.transactions.map(bt => commitOneTransaction(db, state.scanId, bt, fileIdByChunkId)));
|
|
121
|
+
const insertedTx = outcomes.filter(o => o.kind === "ok").length;
|
|
122
|
+
const failures = outcomes.filter((o) => o.kind === "failed");
|
|
123
|
+
const failureUnknowns = failuresToUnknowns(failures);
|
|
124
|
+
writeUnknowns(db, [...snapshot.unknowns, ...failureUnknowns], fileIdByChunkId);
|
|
125
|
+
for (const sfId of scannedFileIds) {
|
|
126
|
+
db.prepare(`UPDATE scanned_files SET status = 'scanned', scanned_at = datetime('now') WHERE id = ?`).run(sfId);
|
|
127
|
+
}
|
|
128
|
+
const unknownsResolved = snapshot.unknowns.filter(u => u.answer !== null).length;
|
|
129
|
+
const unknownsOpen = snapshot.unknowns.length - unknownsResolved + failureUnknowns.length;
|
|
130
|
+
const outcome = {
|
|
131
|
+
transactions: insertedTx,
|
|
132
|
+
accounts: snapshot.accountsCreated.length,
|
|
133
|
+
merchants: snapshot.merchantsCreated.length,
|
|
134
|
+
unknownsOpen,
|
|
135
|
+
unknownsResolved,
|
|
136
|
+
scannedFileIds,
|
|
137
|
+
};
|
|
138
|
+
state.committed = outcome;
|
|
139
|
+
await hooks.afterCommit?.(state, outcome);
|
|
140
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { ScanState } from "../engine.js";
|
|
3
|
+
import type { ScanHooks } from "../hooks.js";
|
|
4
|
+
/**
|
|
5
|
+
* Phase 1 — walk the data dir, optionally filter by regex, decrypt each file
|
|
6
|
+
* sequentially (password prompts can't share a TTY). Output partitions into
|
|
7
|
+
* decrypted / skipped / failed via a kind-keyed dispatch map. Bootstrapped
|
|
8
|
+
* scanned_files rows are tagged onto each DecryptedFile.
|
|
9
|
+
*/
|
|
10
|
+
export declare function decryptPhase(db: Database.Database, state: ScanState, hooks: ScanHooks): Promise<void>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { readPdf } from "../pdf.js";
|
|
3
|
+
import { unlockIfNeeded, persistUnlockOutcome } from "../unlock.js";
|
|
4
|
+
import { scanDataDir } from "../walker.js";
|
|
5
|
+
import { tryExecute } from "../result.js";
|
|
6
|
+
function findScannedByHash(db, hash) {
|
|
7
|
+
return db
|
|
8
|
+
.prepare(`SELECT id FROM scanned_files WHERE file_hash = ?`)
|
|
9
|
+
.get(hash) ?? null;
|
|
10
|
+
}
|
|
11
|
+
async function decryptOne(db, file, opts) {
|
|
12
|
+
const read = await tryExecute(() => readPdf(file.path));
|
|
13
|
+
if (!read.ok)
|
|
14
|
+
return { kind: "failed", error: `read failed: ${read.error}` };
|
|
15
|
+
const pdf = read.value;
|
|
16
|
+
const existing = findScannedByHash(db, pdf.hash);
|
|
17
|
+
if (existing && !opts.force) {
|
|
18
|
+
return { kind: "skipped", existingScannedFileId: existing.id };
|
|
19
|
+
}
|
|
20
|
+
const unlock = await tryExecute(() => unlockIfNeeded({
|
|
21
|
+
db,
|
|
22
|
+
filePath: file.path,
|
|
23
|
+
bytes: pdf.bytes,
|
|
24
|
+
interactive: opts.interactive,
|
|
25
|
+
}));
|
|
26
|
+
if (!unlock.ok)
|
|
27
|
+
return { kind: "failed", error: unlock.error || "unlock failed" };
|
|
28
|
+
persistUnlockOutcome(db, file.path, unlock.value.outcome);
|
|
29
|
+
return {
|
|
30
|
+
kind: "decrypted",
|
|
31
|
+
file: {
|
|
32
|
+
path: file.path,
|
|
33
|
+
fileName: file.name,
|
|
34
|
+
relPath: file.relPath,
|
|
35
|
+
hash: pdf.hash,
|
|
36
|
+
mime: pdf.mime,
|
|
37
|
+
decryptedBytes: unlock.value.decrypted,
|
|
38
|
+
replacesPriorScannedFileId: existing?.id,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const APPLY = {
|
|
43
|
+
decrypted: (state, _file, o) => { state.decrypted.push(o.file); },
|
|
44
|
+
skipped: (state, file, o) => { state.skipped.push({ file, existingScannedFileId: o.existingScannedFileId }); },
|
|
45
|
+
failed: (state, file, o) => { state.failed.push({ file, error: o.error }); },
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Bootstrap one scanned_files row per decrypted file. Chunk workers later
|
|
49
|
+
* stamp transactions with source_file_id, so the row must exist before any
|
|
50
|
+
* tool writes hit the DB. Status flips to 'scanned' after parse completes.
|
|
51
|
+
*/
|
|
52
|
+
function bootstrapScannedFiles(db, state) {
|
|
53
|
+
for (const file of state.decrypted) {
|
|
54
|
+
if (file.replacesPriorScannedFileId) {
|
|
55
|
+
db.prepare(`DELETE FROM scanned_files WHERE id = ?`).run(file.replacesPriorScannedFileId);
|
|
56
|
+
}
|
|
57
|
+
const sfId = `sf:${randomUUID()}`;
|
|
58
|
+
db.prepare(`INSERT INTO scanned_files (id, path, file_hash, mime, status) VALUES (?, ?, ?, ?, 'pending')`).run(sfId, file.path, file.hash, file.mime);
|
|
59
|
+
file.scannedFileId = sfId;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Phase 1 — walk the data dir, optionally filter by regex, decrypt each file
|
|
64
|
+
* sequentially (password prompts can't share a TTY). Output partitions into
|
|
65
|
+
* decrypted / skipped / failed via a kind-keyed dispatch map. Bootstrapped
|
|
66
|
+
* scanned_files rows are tagged onto each DecryptedFile.
|
|
67
|
+
*/
|
|
68
|
+
export async function decryptPhase(db, state, hooks) {
|
|
69
|
+
await hooks.beforeDecrypt?.(state);
|
|
70
|
+
const matcher = state.options.regex ? new RegExp(state.options.regex, "i") : null;
|
|
71
|
+
state.files = scanDataDir().filter(f => (matcher ? matcher.test(f.relPath) : true));
|
|
72
|
+
const interactive = state.options.interactive ?? true;
|
|
73
|
+
const force = !!state.options.force;
|
|
74
|
+
for (const file of state.files) {
|
|
75
|
+
const outcome = await decryptOne(db, file, { force, interactive });
|
|
76
|
+
APPLY[outcome.kind](state, file, outcome);
|
|
77
|
+
}
|
|
78
|
+
bootstrapScannedFiles(db, state);
|
|
79
|
+
await hooks.afterDecrypt?.(state);
|
|
80
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { ScanState } from "../engine.js";
|
|
3
|
+
import type { ScanHooks } from "../hooks.js";
|
|
4
|
+
/**
|
|
5
|
+
* Phase 3 — fan out FileWorkers in parallel. Each FileWorker fans out its
|
|
6
|
+
* file's chunks in parallel internally. The scanId + progress sink are
|
|
7
|
+
* threaded through ScanState; chunk-worker tools write to the DB directly
|
|
8
|
+
* and tick the progress sink as they go.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parsePhase(db: Database.Database, state: ScanState, hooks: ScanHooks): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { runWithConcurrency } from "../concurrency.js";
|
|
2
|
+
import { runFileWorker } from "../file-worker.js";
|
|
3
|
+
import { errorMessage } from "../result.js";
|
|
4
|
+
const DEFAULT_MAX_FILE_WORKERS = 5;
|
|
5
|
+
const DEFAULT_MAX_CHUNK_WORKERS_PER_FILE = 5;
|
|
6
|
+
const HARD_CAP = 8;
|
|
7
|
+
const clamp = (n, fallback) => Math.min(HARD_CAP, Math.max(1, n ?? fallback));
|
|
8
|
+
/**
|
|
9
|
+
* Phase 3 — fan out FileWorkers in parallel. Each FileWorker fans out its
|
|
10
|
+
* file's chunks in parallel internally. The scanId + progress sink are
|
|
11
|
+
* threaded through ScanState; chunk-worker tools write to the DB directly
|
|
12
|
+
* and tick the progress sink as they go.
|
|
13
|
+
*/
|
|
14
|
+
export async function parsePhase(db, state, hooks) {
|
|
15
|
+
await hooks.beforeParse?.(state);
|
|
16
|
+
const maxFile = clamp(state.options.maxFileWorkers, DEFAULT_MAX_FILE_WORKERS);
|
|
17
|
+
const maxChunk = clamp(state.options.maxChunkWorkersPerFile, DEFAULT_MAX_CHUNK_WORKERS_PER_FILE);
|
|
18
|
+
const fileGroups = state.decrypted
|
|
19
|
+
.map(file => ({
|
|
20
|
+
fileId: file.path,
|
|
21
|
+
scannedFileId: file.scannedFileId,
|
|
22
|
+
chunks: state.chunks.filter(c => c.fileId === file.path),
|
|
23
|
+
}))
|
|
24
|
+
.filter(g => g.chunks.length > 0);
|
|
25
|
+
const tasks = fileGroups.map(group => () => runFileWorker({
|
|
26
|
+
db,
|
|
27
|
+
scanId: state.scanId,
|
|
28
|
+
scannedFileId: group.scannedFileId,
|
|
29
|
+
progress: state.progress,
|
|
30
|
+
fileId: group.fileId,
|
|
31
|
+
chunks: group.chunks,
|
|
32
|
+
maxChunkWorkers: maxChunk,
|
|
33
|
+
}, hooks));
|
|
34
|
+
const settled = await runWithConcurrency(tasks, maxFile);
|
|
35
|
+
for (let i = 0; i < settled.length; i++) {
|
|
36
|
+
const r = settled[i];
|
|
37
|
+
if (!r.ok)
|
|
38
|
+
state.errors.push({ phase: "parse", target: fileGroups[i].fileId, error: errorMessage(r.error) });
|
|
39
|
+
}
|
|
40
|
+
for (const file of state.decrypted) {
|
|
41
|
+
if (!file.scannedFileId)
|
|
42
|
+
continue;
|
|
43
|
+
db.prepare(`UPDATE scanned_files SET status = 'scanned', scanned_at = datetime('now') WHERE id = ?`).run(file.scannedFileId);
|
|
44
|
+
}
|
|
45
|
+
await hooks.afterParse?.(state);
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { ScanState } from "../engine.js";
|
|
3
|
+
import type { ScanHooks } from "../hooks.js";
|
|
4
|
+
/**
|
|
5
|
+
* Phase 4 — close every open unknown raised during this scan. Deterministic
|
|
6
|
+
* passes (memory rules, merchant defaults) run first; whatever survives goes
|
|
7
|
+
* to the LLM resolver agent when interactive. Closed unknowns get compacted
|
|
8
|
+
* into scanning_hint memories so the next scan picks them up automatically.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolvePhase(db: Database.Database, state: ScanState, hooks: ScanHooks): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runResolve } from "../resolver.js";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 4 — close every open unknown raised during this scan. Deterministic
|
|
4
|
+
* passes (memory rules, merchant defaults) run first; whatever survives goes
|
|
5
|
+
* to the LLM resolver agent when interactive. Closed unknowns get compacted
|
|
6
|
+
* into scanning_hint memories so the next scan picks them up automatically.
|
|
7
|
+
*/
|
|
8
|
+
export async function resolvePhase(db, state, hooks) {
|
|
9
|
+
await hooks.beforeResolve?.(state);
|
|
10
|
+
const summary = await runResolve({
|
|
11
|
+
db,
|
|
12
|
+
scanId: state.scanId,
|
|
13
|
+
interactive: state.options.interactive ?? true,
|
|
14
|
+
});
|
|
15
|
+
state.resolveSummary = summary;
|
|
16
|
+
await hooks.afterResolve?.(state, summary);
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { ScanState } from "../engine.js";
|
|
3
|
+
import type { ScanHooks } from "../hooks.js";
|
|
4
|
+
/**
|
|
5
|
+
* Phase 4 — present the buffer to the user for confirmation, then set
|
|
6
|
+
* `state.review` to either `"commit"` or `"abort"`. Today this is a simple
|
|
7
|
+
* auto-commit path so the engine compiles; the full Ink TUI is a separate
|
|
8
|
+
* follow-up (review TUI task).
|
|
9
|
+
*/
|
|
10
|
+
export declare function reviewPhase(_db: Database.Database, state: ScanState, hooks: ScanHooks): Promise<void>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4 — present the buffer to the user for confirmation, then set
|
|
3
|
+
* `state.review` to either `"commit"` or `"abort"`. Today this is a simple
|
|
4
|
+
* auto-commit path so the engine compiles; the full Ink TUI is a separate
|
|
5
|
+
* follow-up (review TUI task).
|
|
6
|
+
*/
|
|
7
|
+
export async function reviewPhase(_db, state, hooks) {
|
|
8
|
+
const snapshot = state.buffer.snapshot();
|
|
9
|
+
await hooks.beforeReview?.(state, snapshot);
|
|
10
|
+
state.review = "commit";
|
|
11
|
+
await hooks.afterReview?.(state);
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-typed event sink scan-worker tools emit into as they write to the DB.
|
|
3
|
+
* Replaces the bus + buffer for in-flight progress: one consumer at a time
|
|
4
|
+
* (dashboard or plain-hooks counters) reads ticks per chunk.
|
|
5
|
+
*/
|
|
6
|
+
export interface ScanProgressEvent {
|
|
7
|
+
readonly chunkId: string;
|
|
8
|
+
readonly kind: "tx" | "question";
|
|
9
|
+
}
|
|
10
|
+
export interface ScanProgress {
|
|
11
|
+
emit(event: ScanProgressEvent): void;
|
|
12
|
+
subscribe(handler: (e: ScanProgressEvent) => void): () => void;
|
|
13
|
+
}
|
|
14
|
+
export declare function createProgress(): ScanProgress;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function createProgress() {
|
|
2
|
+
const subscribers = new Set();
|
|
3
|
+
return {
|
|
4
|
+
emit(event) {
|
|
5
|
+
for (const fn of subscribers) {
|
|
6
|
+
try {
|
|
7
|
+
fn(event);
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
console.error(`[progress listener] ${err instanceof Error ? err.message : String(err)}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
subscribe(handler) {
|
|
15
|
+
subscribers.add(handler);
|
|
16
|
+
return () => {
|
|
17
|
+
subscribers.delete(handler);
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { ClosedQuestion } from "../db/queries/questions.js";
|
|
3
|
+
/**
|
|
4
|
+
* Compact every closed question into a memories row (category `scanning_hint`).
|
|
5
|
+
* The next scan's deterministic memoryRulePass picks them up. Dedups on body —
|
|
6
|
+
* an identical rule for the same kind + prompt won't be re-inserted.
|
|
7
|
+
*/
|
|
8
|
+
export declare function synthesizeMemoryRules(db: Database.Database, closures: readonly ClosedQuestion[]): number;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact every closed question into a memories row (category `scanning_hint`).
|
|
3
|
+
* The next scan's deterministic memoryRulePass picks them up. Dedups on body —
|
|
4
|
+
* an identical rule for the same kind + prompt won't be re-inserted.
|
|
5
|
+
*/
|
|
6
|
+
export function synthesizeMemoryRules(db, closures) {
|
|
7
|
+
if (closures.length === 0)
|
|
8
|
+
return 0;
|
|
9
|
+
let inserted = 0;
|
|
10
|
+
const exists = db.prepare(`SELECT 1 FROM memories WHERE category = ? AND content = ? LIMIT 1`);
|
|
11
|
+
const insert = db.prepare(`INSERT INTO memories (content, category) VALUES (?, ?)`);
|
|
12
|
+
for (const c of closures) {
|
|
13
|
+
const body = formatRule(c);
|
|
14
|
+
if (exists.get("scanning_hint", body))
|
|
15
|
+
continue;
|
|
16
|
+
insert.run(body, "scanning_hint");
|
|
17
|
+
inserted++;
|
|
18
|
+
}
|
|
19
|
+
return inserted;
|
|
20
|
+
}
|
|
21
|
+
function formatRule(c) {
|
|
22
|
+
const kindLabel = c.kind ?? "general";
|
|
23
|
+
return `[${kindLabel}] ${c.prompt.replace(/\s+/g, " ").trim()} -> ${c.answer.trim()}`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import { type QuestionRow } from "../db/queries/questions.js";
|
|
3
|
+
export interface ResolverContext {
|
|
4
|
+
readonly db: Database.Database;
|
|
5
|
+
readonly tally: Record<string, number>;
|
|
6
|
+
}
|
|
7
|
+
export interface ResolverPass {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly kinds: readonly string[];
|
|
10
|
+
/** Try to close one question. Returns the answer if closed, else null. */
|
|
11
|
+
tryResolve(u: QuestionRow, ctx: ResolverContext): Promise<string | null>;
|
|
12
|
+
}
|
|
13
|
+
export interface ResolveSummary {
|
|
14
|
+
readonly total: number;
|
|
15
|
+
readonly resolved: number;
|
|
16
|
+
readonly remaining: number;
|
|
17
|
+
readonly tally: Readonly<Record<string, number>>;
|
|
18
|
+
}
|
|
19
|
+
export interface RunResolveOpts {
|
|
20
|
+
db: Database.Database;
|
|
21
|
+
/** Narrows to a single scan's questions. Omit = every question. */
|
|
22
|
+
scanId?: string;
|
|
23
|
+
interactive?: boolean;
|
|
24
|
+
promptUser?: (prompt: string, options?: string[], facts?: any) => Promise<string>;
|
|
25
|
+
onProgress?: (event: {
|
|
26
|
+
phase: "tool" | "responding";
|
|
27
|
+
toolName?: string;
|
|
28
|
+
toolCount: number;
|
|
29
|
+
elapsedMs: number;
|
|
30
|
+
}) => void;
|
|
31
|
+
}
|
|
32
|
+
export declare const RESOLVER_PASSES: readonly ResolverPass[];
|
|
33
|
+
/**
|
|
34
|
+
* Single entry point shared by the in-scan resolve phase and the standalone
|
|
35
|
+
* `plasalid resolve` command. Runs deterministic passes first, then (when
|
|
36
|
+
* interactive) hands the leftovers to the LLM resolver agent. Closed
|
|
37
|
+
* questions get compacted into scanning_hint memories.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runResolve(opts: RunResolveOpts): Promise<ResolveSummary>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { closeQuestion, listQuestions, countQuestions, } from "../db/queries/questions.js";
|
|
2
|
+
import { updatePosting } from "../db/queries/transactions.js";
|
|
3
|
+
import { runResolveAgent } from "../ai/agent.js";
|
|
4
|
+
import { synthesizeMemoryRules } from "./resolver-memory.js";
|
|
5
|
+
import { converge } from "./converge.js";
|
|
6
|
+
const MAX_AGENT_PASSES = 3;
|
|
7
|
+
/**
|
|
8
|
+
* Apply deterministic passes via memory_rules lookups. Closes any question
|
|
9
|
+
* whose prompt has a stored scanning_hint that already encodes the answer.
|
|
10
|
+
*/
|
|
11
|
+
const memoryRulePass = {
|
|
12
|
+
name: "memory_rule",
|
|
13
|
+
kinds: ["uncategorized", "uncategorized_expense", "duplicate", "correlation", "recurrence_candidate", "similar_accounts", "boundary_continuation", "scan_truncated", "scan_commit_failure"],
|
|
14
|
+
async tryResolve(u, ctx) {
|
|
15
|
+
const rules = ctx.db
|
|
16
|
+
.prepare(`SELECT content FROM memories WHERE category = 'scanning_hint'`)
|
|
17
|
+
.all();
|
|
18
|
+
const key = canonicalKey(u);
|
|
19
|
+
for (const r of rules) {
|
|
20
|
+
const match = parseRule(r.content);
|
|
21
|
+
if (!match)
|
|
22
|
+
continue;
|
|
23
|
+
if (match.key === key)
|
|
24
|
+
return match.answer;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* For an uncategorized expense whose transaction has a merchant with a
|
|
31
|
+
* stored default_account_id, apply the default to every expense posting on
|
|
32
|
+
* that transaction.
|
|
33
|
+
*/
|
|
34
|
+
const merchantDefaultPass = {
|
|
35
|
+
name: "merchant_default",
|
|
36
|
+
kinds: ["uncategorized_expense"],
|
|
37
|
+
async tryResolve(u, ctx) {
|
|
38
|
+
if (!u.transaction_id)
|
|
39
|
+
return null;
|
|
40
|
+
const tx = ctx.db
|
|
41
|
+
.prepare(`SELECT merchant_id FROM transactions WHERE id = ?`)
|
|
42
|
+
.get(u.transaction_id);
|
|
43
|
+
if (!tx?.merchant_id)
|
|
44
|
+
return null;
|
|
45
|
+
const merchant = ctx.db
|
|
46
|
+
.prepare(`SELECT default_account_id FROM merchants WHERE id = ?`)
|
|
47
|
+
.get(tx.merchant_id);
|
|
48
|
+
const target = merchant?.default_account_id;
|
|
49
|
+
if (!target)
|
|
50
|
+
return null;
|
|
51
|
+
const postings = ctx.db
|
|
52
|
+
.prepare(`SELECT p.id FROM postings p
|
|
53
|
+
JOIN accounts a ON a.id = p.account_id
|
|
54
|
+
WHERE p.transaction_id = ? AND a.id = 'expense:uncategorized'`)
|
|
55
|
+
.all(u.transaction_id);
|
|
56
|
+
if (postings.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
for (const p of postings) {
|
|
59
|
+
updatePosting(ctx.db, p.id, { account_id: target });
|
|
60
|
+
}
|
|
61
|
+
return target;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
export const RESOLVER_PASSES = [
|
|
65
|
+
memoryRulePass,
|
|
66
|
+
merchantDefaultPass,
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Single entry point shared by the in-scan resolve phase and the standalone
|
|
70
|
+
* `plasalid resolve` command. Runs deterministic passes first, then (when
|
|
71
|
+
* interactive) hands the leftovers to the LLM resolver agent. Closed
|
|
72
|
+
* questions get compacted into scanning_hint memories.
|
|
73
|
+
*/
|
|
74
|
+
export async function runResolve(opts) {
|
|
75
|
+
const { db } = opts;
|
|
76
|
+
const tally = {};
|
|
77
|
+
const closures = [];
|
|
78
|
+
const initial = listQuestions(db, { scanId: opts.scanId, limit: 1000 });
|
|
79
|
+
const total = initial.length;
|
|
80
|
+
if (total === 0) {
|
|
81
|
+
return { total: 0, resolved: 0, remaining: 0, tally };
|
|
82
|
+
}
|
|
83
|
+
for (const u of initial) {
|
|
84
|
+
const passes = matchingPasses(u);
|
|
85
|
+
if (passes.length === 0)
|
|
86
|
+
continue;
|
|
87
|
+
const result = await tryPasses(u, passes, { db, tally });
|
|
88
|
+
if (!result)
|
|
89
|
+
continue;
|
|
90
|
+
const closed = closeQuestion(db, u.id, result.answer);
|
|
91
|
+
if (!closed)
|
|
92
|
+
continue;
|
|
93
|
+
closures.push(closed);
|
|
94
|
+
tally[result.passName] = (tally[result.passName] ?? 0) + 1;
|
|
95
|
+
}
|
|
96
|
+
const interactive = opts.interactive ?? true;
|
|
97
|
+
if (interactive && countRemaining(db, opts.scanId) > 0) {
|
|
98
|
+
await runAgentLoop(opts, closures, tally);
|
|
99
|
+
}
|
|
100
|
+
synthesizeMemoryRules(db, closures);
|
|
101
|
+
const remaining = countRemaining(db, opts.scanId);
|
|
102
|
+
return { total, resolved: total - remaining, remaining, tally };
|
|
103
|
+
}
|
|
104
|
+
function matchingPasses(u) {
|
|
105
|
+
if (!u.kind)
|
|
106
|
+
return [];
|
|
107
|
+
return RESOLVER_PASSES.filter(p => p.kinds.includes(u.kind));
|
|
108
|
+
}
|
|
109
|
+
async function tryPasses(u, passes, ctx) {
|
|
110
|
+
for (const pass of passes) {
|
|
111
|
+
let answer;
|
|
112
|
+
try {
|
|
113
|
+
answer = await pass.tryResolve(u, ctx);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
console.error(`[resolver pass ${pass.name}] ${err instanceof Error ? err.message : String(err)}`);
|
|
117
|
+
answer = null;
|
|
118
|
+
}
|
|
119
|
+
if (answer != null)
|
|
120
|
+
return { passName: pass.name, answer };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
function countRemaining(db, scanId) {
|
|
125
|
+
return scanId ? countQuestions(db, { scan_id: scanId }) : countQuestions(db);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Stall-protected outer loop around the LLM resolver. Each pass re-fetches
|
|
129
|
+
* leftover questions, hands them to the agent, and the agent closes what it
|
|
130
|
+
* can via close_question / ask_user. The loop stops when nothing closes
|
|
131
|
+
* between passes. After each pass we diff the pre/post set to recover the
|
|
132
|
+
* (prompt, kind, answer) tuples the agent closed without going through the
|
|
133
|
+
* memoryRulePass path.
|
|
134
|
+
*/
|
|
135
|
+
async function runAgentLoop(opts, closures, tally) {
|
|
136
|
+
const { db } = opts;
|
|
137
|
+
await converge({
|
|
138
|
+
initial: countRemaining(db, opts.scanId),
|
|
139
|
+
maxAttempts: MAX_AGENT_PASSES,
|
|
140
|
+
isDone: (n) => n === 0,
|
|
141
|
+
isStalled: (curr, prev) => curr >= prev,
|
|
142
|
+
onPass: async () => {
|
|
143
|
+
const before = listQuestions(db, { scanId: opts.scanId, limit: 1000 });
|
|
144
|
+
if (before.length === 0)
|
|
145
|
+
return 0;
|
|
146
|
+
await runResolveAgent({
|
|
147
|
+
db,
|
|
148
|
+
prompt: {},
|
|
149
|
+
initialMessages: [{ role: "user", content: buildResolveUserMessage(before) }],
|
|
150
|
+
agentCtx: {
|
|
151
|
+
interactive: true,
|
|
152
|
+
promptUser: opts.promptUser,
|
|
153
|
+
onQuestionClosed: (closed) => {
|
|
154
|
+
closures.push(closed);
|
|
155
|
+
tally["agent_resolution"] = (tally["agent_resolution"] ?? 0) + 1;
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
onProgress: opts.onProgress,
|
|
159
|
+
});
|
|
160
|
+
return countRemaining(db, opts.scanId);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function buildResolveUserMessage(questions) {
|
|
165
|
+
const lines = [`${questions.length} question(s) to resolve.`, ``, `Questions:`];
|
|
166
|
+
for (const c of questions) {
|
|
167
|
+
const options = parseOptions(c.options_json);
|
|
168
|
+
const optionsStr = options.length > 0 ? ` | options=[${options.join(" / ")}]` : "";
|
|
169
|
+
lines.push(`- ${c.id} | kind=${c.kind ?? "(none)"} | tx=${c.transaction_id ?? "(none)"} | acct=${c.account_id ?? "(none)"} | file=${c.file_id ?? "(none)"}${optionsStr}`, ` prompt: ${c.prompt.replace(/\n/g, " ")}`);
|
|
170
|
+
}
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
}
|
|
173
|
+
function parseOptions(json) {
|
|
174
|
+
if (!json)
|
|
175
|
+
return [];
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(json);
|
|
178
|
+
return Array.isArray(parsed) ? parsed.filter((o) => typeof o === "string") : [];
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function canonicalKey(u) {
|
|
185
|
+
return `[${u.kind ?? "general"}] ${u.prompt.replace(/\s+/g, " ").trim()}`;
|
|
186
|
+
}
|
|
187
|
+
function parseRule(body) {
|
|
188
|
+
const idx = body.lastIndexOf(" -> ");
|
|
189
|
+
if (idx < 0)
|
|
190
|
+
return null;
|
|
191
|
+
const key = body.slice(0, idx).trim();
|
|
192
|
+
const answer = body.slice(idx + 4).trim();
|
|
193
|
+
if (!key || !answer)
|
|
194
|
+
return null;
|
|
195
|
+
return { key, answer };
|
|
196
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Result helpers shared across scanner subdomains. Use this
|
|
3
|
+
* instead of inline try/catch when a function can fail with a human-readable
|
|
4
|
+
* reason and the caller needs to branch on the outcome (decrypt, chunk parse,
|
|
5
|
+
* commit-one-transaction). Distinct from concurrency.ts `Settled<T>` — that
|
|
6
|
+
* type is owned by `runWithConcurrency` and includes an `error: unknown`;
|
|
7
|
+
* `Result<T>` stringifies the error up front for ergonomic message handling.
|
|
8
|
+
*/
|
|
9
|
+
export type Result<T> = {
|
|
10
|
+
ok: true;
|
|
11
|
+
value: T;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
error: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function errorMessage(err: unknown): string;
|
|
17
|
+
export declare function tryExecute<T>(fn: () => Promise<T> | T): Promise<Result<T>>;
|