plasalid 0.3.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -43
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +19 -5
- package/dist/ai/agent.js +26 -6
- package/dist/ai/memory.d.ts +14 -5
- package/dist/ai/memory.js +12 -0
- package/dist/ai/personas.d.ts +11 -0
- package/dist/ai/personas.js +193 -0
- package/dist/ai/prompt-sections.d.ts +49 -0
- package/dist/ai/prompt-sections.js +107 -0
- package/dist/ai/system-prompt.d.ts +14 -3
- package/dist/ai/system-prompt.js +59 -165
- package/dist/ai/thinking.js +1 -1
- package/dist/ai/tools/common.js +2 -5
- package/dist/ai/tools/index.js +32 -7
- package/dist/ai/tools/ingest.d.ts +3 -1
- package/dist/ai/tools/ingest.js +372 -124
- package/dist/ai/tools/merchants.d.ts +2 -0
- package/dist/ai/tools/merchants.js +117 -0
- package/dist/ai/tools/read.js +57 -24
- package/dist/ai/tools/record.d.ts +2 -0
- package/dist/ai/tools/record.js +188 -0
- package/dist/ai/tools/review.d.ts +2 -0
- package/dist/ai/tools/review.js +359 -0
- package/dist/ai/tools/scan.js +5 -3
- package/dist/ai/tools/types.d.ts +33 -4
- package/dist/cli/commands/accounts.js +33 -25
- package/dist/cli/commands/record.d.ts +4 -0
- package/dist/cli/commands/record.js +119 -0
- package/dist/cli/commands/revert.js +1 -1
- package/dist/cli/commands/review.d.ts +2 -0
- package/dist/cli/commands/review.js +15 -0
- package/dist/cli/commands/scan.d.ts +4 -2
- package/dist/cli/commands/scan.js +143 -19
- package/dist/cli/commands/status.js +6 -9
- package/dist/cli/commands/transactions.js +36 -41
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.js +7 -2
- package/dist/cli/index.js +28 -13
- package/dist/cli/ink/scan_dashboard.d.ts +38 -0
- package/dist/cli/ink/scan_dashboard.js +62 -0
- package/dist/cli/setup.d.ts +0 -1
- package/dist/cli/setup.js +2 -8
- package/dist/cli/ux.d.ts +2 -1
- package/dist/cli/ux.js +36 -2
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +12 -1
- package/dist/db/queries/account_balance.d.ts +84 -4
- package/dist/db/queries/account_balance.js +239 -20
- package/dist/db/queries/action_log.d.ts +29 -0
- package/dist/db/queries/action_log.js +27 -0
- package/dist/db/queries/concerns.d.ts +50 -0
- package/dist/db/queries/concerns.js +91 -0
- package/dist/db/queries/journal.d.ts +75 -8
- package/dist/db/queries/journal.js +131 -19
- package/dist/db/queries/merchants.d.ts +42 -0
- package/dist/db/queries/merchants.js +120 -0
- package/dist/db/queries/recurrences.d.ts +33 -0
- package/dist/db/queries/recurrences.js +128 -0
- package/dist/db/queries/search.d.ts +5 -4
- package/dist/db/queries/search.js +16 -12
- package/dist/db/queries/transactions.d.ts +167 -0
- package/dist/db/queries/transactions.js +320 -0
- package/dist/db/schema.js +74 -9
- package/dist/reviewer/pipeline.d.ts +18 -0
- package/dist/reviewer/pipeline.js +46 -0
- package/dist/reviewer/prompts.d.ts +12 -0
- package/dist/reviewer/prompts.js +22 -0
- package/dist/scanner/account_mutex.d.ts +1 -0
- package/dist/scanner/account_mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +51 -0
- package/dist/scanner/buffer.js +63 -0
- package/dist/scanner/concurrency.d.ts +14 -0
- package/dist/scanner/concurrency.js +31 -0
- package/dist/scanner/decrypt_queue.d.ts +57 -0
- package/dist/scanner/decrypt_queue.js +96 -0
- package/dist/scanner/pipeline.d.ts +47 -18
- package/dist/scanner/pipeline.js +247 -97
- package/dist/scanner/prompts.js +3 -3
- package/package.json +2 -2
package/dist/scanner/pipeline.js
CHANGED
|
@@ -1,64 +1,73 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { getDb } from "../db/connection.js";
|
|
3
|
+
import { countOpenConcerns, } from "../db/queries/concerns.js";
|
|
4
|
+
import { correlatePairs } from "../db/queries/transactions.js";
|
|
3
5
|
import { runScanAgent } from "../ai/agent.js";
|
|
4
|
-
import {
|
|
5
|
-
import { readPdf, buildDocumentBlock } from "./pdf.js";
|
|
6
|
+
import { buildDocumentBlock } from "./pdf.js";
|
|
6
7
|
import { buildScanUserMessage } from "./prompts.js";
|
|
7
8
|
import { scanDataDir } from "./walker.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.get(hash) ?? null;
|
|
14
|
-
}
|
|
15
|
-
function deleteScannedFile(db, id) {
|
|
16
|
-
db.prepare(`DELETE FROM scanned_files WHERE id = ?`).run(id);
|
|
17
|
-
}
|
|
18
|
-
function insertScannedFile(db, args) {
|
|
19
|
-
const id = `sf:${randomUUID()}`;
|
|
20
|
-
db.prepare(`INSERT INTO scanned_files (id, path, file_hash, mime, status)
|
|
21
|
-
VALUES (?, ?, ?, ?, 'pending')`).run(id, args.path, args.hash, args.mime);
|
|
22
|
-
return id;
|
|
23
|
-
}
|
|
24
|
-
function countPendingQuestions(db, fileId) {
|
|
25
|
-
const row = db
|
|
26
|
-
.prepare(`SELECT COUNT(*) as n FROM pending_questions WHERE file_id = ? AND resolved_at IS NULL`)
|
|
27
|
-
.get(fileId);
|
|
28
|
-
return row.n;
|
|
29
|
-
}
|
|
30
|
-
function setFileStatus(db, id, status, fields = {}) {
|
|
31
|
-
db.prepare(`UPDATE scanned_files
|
|
32
|
-
SET status = ?, scanned_at = datetime('now'), error = ?, raw_text = COALESCE(?, raw_text)
|
|
33
|
-
WHERE id = ?`).run(status, fields.error ?? null, fields.raw_text ?? null, id);
|
|
9
|
+
import { BufferedWriteContext } from "./buffer.js";
|
|
10
|
+
import { runWithConcurrency } from "./concurrency.js";
|
|
11
|
+
import { decryptQueue, confirmProceedAfterFailures, } from "./decrypt_queue.js";
|
|
12
|
+
export function compileMatcher(input) {
|
|
13
|
+
return new RegExp(input, "i");
|
|
34
14
|
}
|
|
35
|
-
|
|
36
|
-
export async function
|
|
15
|
+
/** Orchestration */
|
|
16
|
+
export async function runScan(opts = {}) {
|
|
37
17
|
const db = getDb();
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return
|
|
18
|
+
const matcher = opts.regex ? compileMatcher(opts.regex) : null;
|
|
19
|
+
const allFiles = scanDataDir().filter(f => (matcher ? matcher.test(f.relPath) : true));
|
|
20
|
+
const concurrency = Math.min(8, Math.max(1, opts.concurrency ?? 3));
|
|
21
|
+
const interactive = opts.interactive ?? true;
|
|
22
|
+
const events = opts.events;
|
|
23
|
+
// Phase 1 — decrypt all
|
|
24
|
+
events?.decryptStart?.(allFiles.length);
|
|
25
|
+
const decryptResult = await decryptQueue(db, allFiles, {
|
|
26
|
+
force: !!opts.force,
|
|
27
|
+
interactive,
|
|
28
|
+
onProgress: events?.decryptProgress,
|
|
29
|
+
});
|
|
30
|
+
events?.decryptDone?.({
|
|
31
|
+
decrypted: decryptResult.decrypted.length,
|
|
32
|
+
skipped: decryptResult.skipped.length,
|
|
33
|
+
failed: decryptResult.failed.length,
|
|
34
|
+
});
|
|
35
|
+
const proceed = await confirmProceedAfterFailures(decryptResult, interactive);
|
|
36
|
+
if (!proceed) {
|
|
37
|
+
return buildAbortedSummary(allFiles.length, decryptResult);
|
|
58
38
|
}
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
39
|
+
// Phase 2 — parallel scan with buffered writes
|
|
40
|
+
const scanResults = await scanInParallel(db, decryptResult.decrypted, { concurrency, events });
|
|
41
|
+
// Phase 3 — cross-file correlation pre-commit
|
|
42
|
+
const pairCount = applyCrossFileCorrelations(scanResults);
|
|
43
|
+
events?.correlating?.(pairCount);
|
|
44
|
+
// Phase 4 — per-file commit
|
|
45
|
+
events?.committing?.();
|
|
46
|
+
const fileResults = commitAll(db, decryptResult, scanResults);
|
|
47
|
+
return buildSummary(allFiles.length, fileResults, decryptResult);
|
|
48
|
+
}
|
|
49
|
+
async function scanInParallel(db, files, opts) {
|
|
50
|
+
const tasks = files.map(f => () => scanOneFile(db, f, opts.events));
|
|
51
|
+
const settled = await runWithConcurrency(tasks, opts.concurrency);
|
|
52
|
+
// Worker errors are captured per-slot by runWithConcurrency. scanOneFile
|
|
53
|
+
// itself catches LLM errors and returns a ScanWorkResult with `error` set,
|
|
54
|
+
// so the `{error}` branch only fires for truly unexpected throws.
|
|
55
|
+
return settled.map((r, i) => {
|
|
56
|
+
if (r && typeof r === "object" && "error" in r && !("buffer" in r)) {
|
|
57
|
+
return {
|
|
58
|
+
decryptedFile: files[i],
|
|
59
|
+
buffer: new BufferedWriteContext(files[i].fileName),
|
|
60
|
+
error: String(r.error),
|
|
61
|
+
agentText: "",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return r;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function scanOneFile(db, file, events) {
|
|
68
|
+
const buffer = new BufferedWriteContext(file.fileName);
|
|
69
|
+
events?.scanStart?.({ fileName: file.fileName });
|
|
70
|
+
const block = buildDocumentBlock(file.decryptedBytes, file.fileName, file.mime);
|
|
62
71
|
const messages = [
|
|
63
72
|
{
|
|
64
73
|
role: "user",
|
|
@@ -68,70 +77,211 @@ export async function scanFile(filePath, opts = {}) {
|
|
|
68
77
|
],
|
|
69
78
|
},
|
|
70
79
|
];
|
|
71
|
-
const spinner = statusSpinner(`Scanning ${file.fileName}...`);
|
|
72
|
-
let summary = "";
|
|
73
80
|
try {
|
|
74
81
|
const text = await runScanAgent({
|
|
75
82
|
db,
|
|
76
83
|
initialMessages: messages,
|
|
77
84
|
prompt: { fileName: file.fileName },
|
|
78
85
|
agentCtx: {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
promptUser: opts.interactive === false ? undefined : makePromptUser(spinner),
|
|
82
|
-
onComplete: (s) => { summary = s; },
|
|
86
|
+
interactive: false,
|
|
87
|
+
buffer,
|
|
83
88
|
},
|
|
84
|
-
onProgress:
|
|
89
|
+
onProgress: (event) => {
|
|
90
|
+
if (event.phase === "tool" && event.toolName) {
|
|
91
|
+
events?.scanProgress?.({ fileName: file.fileName, step: event.toolName });
|
|
92
|
+
}
|
|
93
|
+
else if (event.phase === "responding") {
|
|
94
|
+
events?.scanProgress?.({ fileName: file.fileName, step: "thinking" });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
events?.scanEnd?.({
|
|
99
|
+
fileName: file.fileName,
|
|
100
|
+
status: "scanned",
|
|
101
|
+
transactions: buffer.transactions.length,
|
|
102
|
+
concerns: buffer.concerns.length,
|
|
85
103
|
});
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
return { decryptedFile: file, buffer, agentText: text };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const message = err?.message ?? "agent error";
|
|
108
|
+
events?.scanEnd?.({
|
|
109
|
+
fileName: file.fileName,
|
|
110
|
+
status: "failed",
|
|
111
|
+
transactions: 0,
|
|
112
|
+
concerns: 0,
|
|
113
|
+
error: message,
|
|
114
|
+
});
|
|
115
|
+
return { decryptedFile: file, buffer, error: message, agentText: "" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Phase 3: cross-file correlation */
|
|
119
|
+
/**
|
|
120
|
+
* For every pair of buffered entries that look like the same money movement
|
|
121
|
+
* across two different files, append a mirror concern to each side's buffer.
|
|
122
|
+
* Returns the number of pairs detected so the CLI can report it.
|
|
123
|
+
*/
|
|
124
|
+
function applyCrossFileCorrelations(results) {
|
|
125
|
+
const all = [];
|
|
126
|
+
for (const res of results) {
|
|
127
|
+
if (res.error)
|
|
128
|
+
continue;
|
|
129
|
+
for (const bt of res.buffer.transactions) {
|
|
130
|
+
all.push({
|
|
131
|
+
file: res,
|
|
132
|
+
transactionId: bt.transaction_id,
|
|
133
|
+
postings: bt.input.postings,
|
|
134
|
+
date: bt.input.date,
|
|
135
|
+
description: bt.input.description,
|
|
136
|
+
});
|
|
91
137
|
}
|
|
92
|
-
|
|
93
|
-
|
|
138
|
+
}
|
|
139
|
+
const candidates = all.map(e => {
|
|
140
|
+
const debit = e.postings.reduce((s, p) => s + (p.debit ?? 0), 0);
|
|
141
|
+
const currency = e.postings.find(p => p.currency)?.currency ?? "THB";
|
|
142
|
+
const ids = Array.from(new Set(e.postings.map(p => p.account_id)));
|
|
94
143
|
return {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
id: e.transactionId,
|
|
145
|
+
date: e.date,
|
|
146
|
+
description: e.description,
|
|
147
|
+
amount: Math.round(debit * 100) / 100,
|
|
148
|
+
currency,
|
|
149
|
+
account_ids: ids,
|
|
150
|
+
account_names: ids,
|
|
99
151
|
};
|
|
152
|
+
});
|
|
153
|
+
const pairs = correlatePairs(candidates, { toleranceDays: 3 });
|
|
154
|
+
const byTransaction = new Map(all.map(a => [a.transactionId, a]));
|
|
155
|
+
for (const pair of pairs) {
|
|
156
|
+
const a = byTransaction.get(pair.a.id);
|
|
157
|
+
const b = byTransaction.get(pair.b.id);
|
|
158
|
+
if (!a || !b)
|
|
159
|
+
continue;
|
|
160
|
+
if (a.file === b.file)
|
|
161
|
+
continue;
|
|
162
|
+
const amountStr = `฿${pair.amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
163
|
+
a.file.buffer.appendConcern({
|
|
164
|
+
transaction_id: a.transactionId,
|
|
165
|
+
account_id: null,
|
|
166
|
+
prompt: `Looks like the matching half of this ${amountStr} movement on ${a.date} was also recorded in ${b.file.decryptedFile.fileName} on ${b.date}. Merge during review?`,
|
|
167
|
+
options: ["Yes — merge into one transaction", "No — these are two real events", "Skip — leave as is"],
|
|
168
|
+
});
|
|
169
|
+
b.file.buffer.appendConcern({
|
|
170
|
+
transaction_id: b.transactionId,
|
|
171
|
+
account_id: null,
|
|
172
|
+
prompt: `Looks like the matching half of this ${amountStr} movement on ${b.date} was also recorded in ${a.file.decryptedFile.fileName} on ${a.date}. Merge during review?`,
|
|
173
|
+
options: ["Yes — merge into one transaction", "No — these are two real events", "Skip — leave as is"],
|
|
174
|
+
});
|
|
100
175
|
}
|
|
101
|
-
|
|
102
|
-
setFileStatus(db, fileId, "failed", { error: err.message });
|
|
103
|
-
spinner.fail(`${file.fileName} failed: ${err.message}`);
|
|
104
|
-
return { fileId, status: "failed", error: err.message, pendingQuestions: countPendingQuestions(db, fileId) };
|
|
105
|
-
}
|
|
176
|
+
return pairs.filter(p => byTransaction.get(p.a.id)?.file !== byTransaction.get(p.b.id)?.file).length;
|
|
106
177
|
}
|
|
107
|
-
|
|
108
|
-
|
|
178
|
+
/** Phase 4: commit */
|
|
179
|
+
function commitAll(db, decryptResult, scanResults) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const skipped of decryptResult.skipped) {
|
|
182
|
+
out.push({
|
|
183
|
+
name: skipped.file.name,
|
|
184
|
+
relPath: skipped.file.relPath,
|
|
185
|
+
status: "skipped",
|
|
186
|
+
transactions: 0,
|
|
187
|
+
concerns: countOpenConcerns(db, { file_id: skipped.existingScannedFileId }),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
for (const failed of decryptResult.failed) {
|
|
191
|
+
out.push({
|
|
192
|
+
name: failed.file.name,
|
|
193
|
+
relPath: failed.file.relPath,
|
|
194
|
+
status: "failed",
|
|
195
|
+
transactions: 0,
|
|
196
|
+
concerns: 0,
|
|
197
|
+
error: failed.error,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
for (const res of scanResults) {
|
|
201
|
+
const { decryptedFile, buffer, error, agentText } = res;
|
|
202
|
+
if (error) {
|
|
203
|
+
out.push({
|
|
204
|
+
name: decryptedFile.fileName,
|
|
205
|
+
relPath: decryptedFile.relPath,
|
|
206
|
+
status: "failed",
|
|
207
|
+
transactions: 0,
|
|
208
|
+
concerns: buffer.concerns.length,
|
|
209
|
+
error,
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
if (decryptedFile.replacesPriorScannedFileId) {
|
|
215
|
+
deleteScannedFile(db, decryptedFile.replacesPriorScannedFileId);
|
|
216
|
+
}
|
|
217
|
+
const scannedFileId = insertScannedFile(db, {
|
|
218
|
+
path: decryptedFile.path,
|
|
219
|
+
hash: decryptedFile.hash,
|
|
220
|
+
mime: decryptedFile.mime,
|
|
221
|
+
});
|
|
222
|
+
const counts = buffer.commit(db, scannedFileId);
|
|
223
|
+
setFileStatus(db, scannedFileId, "scanned", { raw_text: agentText });
|
|
224
|
+
out.push({
|
|
225
|
+
name: decryptedFile.fileName,
|
|
226
|
+
relPath: decryptedFile.relPath,
|
|
227
|
+
status: decryptedFile.replacesPriorScannedFileId ? "replaced" : "scanned",
|
|
228
|
+
transactions: counts.transactions,
|
|
229
|
+
concerns: counts.concerns,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
out.push({
|
|
234
|
+
name: decryptedFile.fileName,
|
|
235
|
+
relPath: decryptedFile.relPath,
|
|
236
|
+
status: "failed",
|
|
237
|
+
transactions: 0,
|
|
238
|
+
concerns: buffer.concerns.length,
|
|
239
|
+
error: err?.message ?? "commit failed",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
109
244
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const files = scanDataDir().filter(f => (matcher ? matcher.test(f.relPath) : true));
|
|
245
|
+
/** Summary assembly */
|
|
246
|
+
function buildSummary(total, details, _decrypt) {
|
|
113
247
|
const summary = {
|
|
114
|
-
total
|
|
248
|
+
total,
|
|
115
249
|
scanned: 0,
|
|
116
250
|
replaced: 0,
|
|
117
251
|
skipped: 0,
|
|
118
|
-
needsInput: 0,
|
|
119
252
|
failed: 0,
|
|
120
|
-
|
|
253
|
+
concerns: 0,
|
|
254
|
+
details,
|
|
121
255
|
};
|
|
122
|
-
for (const
|
|
123
|
-
|
|
124
|
-
summary.
|
|
125
|
-
if (result.status === "scanned")
|
|
126
|
-
summary.scanned++;
|
|
127
|
-
else if (result.status === "replaced")
|
|
128
|
-
summary.replaced++;
|
|
129
|
-
else if (result.status === "skipped")
|
|
130
|
-
summary.skipped++;
|
|
131
|
-
else if (result.status === "needs_input")
|
|
132
|
-
summary.needsInput++;
|
|
133
|
-
else if (result.status === "failed")
|
|
134
|
-
summary.failed++;
|
|
256
|
+
for (const d of details) {
|
|
257
|
+
summary[d.status]++;
|
|
258
|
+
summary.concerns += d.concerns;
|
|
135
259
|
}
|
|
136
260
|
return summary;
|
|
137
261
|
}
|
|
262
|
+
function buildAbortedSummary(total, decrypt) {
|
|
263
|
+
const details = [
|
|
264
|
+
...decrypt.skipped.map(s => ({
|
|
265
|
+
name: s.file.name, relPath: s.file.relPath, status: "skipped", transactions: 0, concerns: 0,
|
|
266
|
+
})),
|
|
267
|
+
...decrypt.failed.map(f => ({
|
|
268
|
+
name: f.file.name, relPath: f.file.relPath, status: "failed", transactions: 0, concerns: 0, error: f.error,
|
|
269
|
+
})),
|
|
270
|
+
];
|
|
271
|
+
return buildSummary(total, details, decrypt);
|
|
272
|
+
}
|
|
273
|
+
/** Low-level DB helpers */
|
|
274
|
+
function deleteScannedFile(db, id) {
|
|
275
|
+
db.prepare(`DELETE FROM scanned_files WHERE id = ?`).run(id);
|
|
276
|
+
}
|
|
277
|
+
function insertScannedFile(db, args) {
|
|
278
|
+
const id = `sf:${randomUUID()}`;
|
|
279
|
+
db.prepare(`INSERT INTO scanned_files (id, path, file_hash, mime, status)
|
|
280
|
+
VALUES (?, ?, ?, ?, 'pending')`).run(id, args.path, args.hash, args.mime);
|
|
281
|
+
return id;
|
|
282
|
+
}
|
|
283
|
+
function setFileStatus(db, id, status, fields = {}) {
|
|
284
|
+
db.prepare(`UPDATE scanned_files
|
|
285
|
+
SET status = ?, scanned_at = datetime('now'), error = ?, raw_text = COALESCE(?, raw_text)
|
|
286
|
+
WHERE id = ?`).run(status, fields.error ?? null, fields.raw_text ?? null, id);
|
|
287
|
+
}
|
package/dist/scanner/prompts.js
CHANGED
|
@@ -11,10 +11,10 @@ export function buildScanUserMessage(opts) {
|
|
|
11
11
|
`Steps:`,
|
|
12
12
|
`1. Call list_accounts to see what already exists.`,
|
|
13
13
|
`2. Infer the primary account type (asset / liability / income / expense) from the document's header, account type field, and transaction patterns.`,
|
|
14
|
-
`3. If this document references an account that isn't yet in the chart, call create_account once. Mask the account number to the last 4 digits.`,
|
|
14
|
+
`3. If this document references an account that isn't yet in the chart, call create_account once (pass parent_id under the matching top-level type root). Mask the account number to the last 4 digits.`,
|
|
15
15
|
`4. Persist any document-level metadata you find (statement_day, due_day, points_balance, etc.) using update_account_metadata.`,
|
|
16
|
-
`5. For every transaction in the document, call
|
|
17
|
-
`6. If a row is ambiguous, call
|
|
16
|
+
`5. For every transaction in the document, call record_transaction with balanced debit/credit postings. Attach a merchant block (canonical_name + alias + default_account_id when categorization is confident) for any external counter-party. Reuse existing accounts; create expense categories under their parent (e.g. expense:food before expense:food:groceries) as needed. When you cannot categorize confidently, post the expense side to expense:uncategorized and call note_concern with kind="uncategorized_expense".`,
|
|
17
|
+
`6. Never pause to ask the user. If a row is ambiguous, post your best-guess transaction first, then call note_concern with details and the new transaction_id. If a row is truly unparseable, skip it and call note_concern with the raw row text (no transaction_id). A missing row is better than a wrong row.`,
|
|
18
18
|
`7. When you are done, call mark_file_scanned with a short summary.`,
|
|
19
19
|
].join("\n");
|
|
20
20
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plasalid",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Plasalid — The Harness for Personal Finance",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"finance",
|
|
7
7
|
"personal-finance",
|