plasalid 0.2.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/LICENSE +213 -0
- package/README.md +176 -0
- package/dist/accounts/taxonomy.d.ts +31 -0
- package/dist/accounts/taxonomy.js +189 -0
- package/dist/ai/agent.d.ts +43 -0
- package/dist/ai/agent.js +155 -0
- package/dist/ai/context.d.ts +4 -0
- package/dist/ai/context.js +33 -0
- package/dist/ai/memory.d.ts +14 -0
- package/dist/ai/memory.js +12 -0
- package/dist/ai/provider.d.ts +67 -0
- package/dist/ai/provider.js +5 -0
- package/dist/ai/providers/anthropic.d.ts +5 -0
- package/dist/ai/providers/anthropic.js +49 -0
- package/dist/ai/providers/index.d.ts +2 -0
- package/dist/ai/providers/index.js +12 -0
- package/dist/ai/providers/openai-compat.d.ts +5 -0
- package/dist/ai/providers/openai-compat.js +147 -0
- package/dist/ai/providers/openai.d.ts +5 -0
- package/dist/ai/providers/openai.js +147 -0
- package/dist/ai/redactor.d.ts +2 -0
- package/dist/ai/redactor.js +91 -0
- package/dist/ai/sanitize.d.ts +14 -0
- package/dist/ai/sanitize.js +25 -0
- package/dist/ai/system-prompt.d.ts +13 -0
- package/dist/ai/system-prompt.js +174 -0
- package/dist/ai/thai-taxonomy-hint.d.ts +8 -0
- package/dist/ai/thai-taxonomy-hint.js +22 -0
- package/dist/ai/thinking-phrases.d.ts +7 -0
- package/dist/ai/thinking-phrases.js +15 -0
- package/dist/ai/thinking.d.ts +7 -0
- package/dist/ai/thinking.js +15 -0
- package/dist/ai/tools/common.d.ts +2 -0
- package/dist/ai/tools/common.js +83 -0
- package/dist/ai/tools/index.d.ts +8 -0
- package/dist/ai/tools/index.js +34 -0
- package/dist/ai/tools/ingest.d.ts +2 -0
- package/dist/ai/tools/ingest.js +202 -0
- package/dist/ai/tools/read.d.ts +2 -0
- package/dist/ai/tools/read.js +123 -0
- package/dist/ai/tools/reconcile.d.ts +2 -0
- package/dist/ai/tools/reconcile.js +227 -0
- package/dist/ai/tools/scan.d.ts +2 -0
- package/dist/ai/tools/scan.js +24 -0
- package/dist/ai/tools/types.d.ts +26 -0
- package/dist/ai/tools/types.js +1 -0
- package/dist/ai/tools.d.ts +18 -0
- package/dist/ai/tools.js +402 -0
- package/dist/cli/chat.d.ts +1 -0
- package/dist/cli/chat.js +28 -0
- package/dist/cli/commands/accounts.d.ts +1 -0
- package/dist/cli/commands/accounts.js +86 -0
- package/dist/cli/commands/data.d.ts +1 -0
- package/dist/cli/commands/data.js +28 -0
- package/dist/cli/commands/reconcile.d.ts +2 -0
- package/dist/cli/commands/reconcile.js +15 -0
- package/dist/cli/commands/revert.d.ts +1 -0
- package/dist/cli/commands/revert.js +68 -0
- package/dist/cli/commands/scan.d.ts +4 -0
- package/dist/cli/commands/scan.js +45 -0
- package/dist/cli/commands/status.d.ts +1 -0
- package/dist/cli/commands/status.js +22 -0
- package/dist/cli/commands/transactions.d.ts +8 -0
- package/dist/cli/commands/transactions.js +92 -0
- package/dist/cli/commands/undo.d.ts +1 -0
- package/dist/cli/commands/undo.js +38 -0
- package/dist/cli/commands.d.ts +14 -0
- package/dist/cli/commands.js +196 -0
- package/dist/cli/format.d.ts +8 -0
- package/dist/cli/format.js +109 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +126 -0
- package/dist/cli/ink/ChatApp.d.ts +8 -0
- package/dist/cli/ink/ChatApp.js +94 -0
- package/dist/cli/ink/PromptFrame.d.ts +10 -0
- package/dist/cli/ink/PromptFrame.js +11 -0
- package/dist/cli/ink/TextInput.d.ts +13 -0
- package/dist/cli/ink/TextInput.js +24 -0
- package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
- package/dist/cli/ink/hooks/useAgent.js +65 -0
- package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
- package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
- package/dist/cli/ink/hooks/useFooterText.d.ts +2 -0
- package/dist/cli/ink/hooks/useFooterText.js +43 -0
- package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
- package/dist/cli/ink/hooks/useTextInput.js +356 -0
- package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
- package/dist/cli/ink/messages/AssistantMessage.js +6 -0
- package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
- package/dist/cli/ink/messages/ErrorMessage.js +6 -0
- package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
- package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
- package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
- package/dist/cli/ink/messages/ThinkingLine.js +23 -0
- package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
- package/dist/cli/ink/messages/UserMessage.js +15 -0
- package/dist/cli/ink/mount.d.ts +6 -0
- package/dist/cli/ink/mount.js +12 -0
- package/dist/cli/logo.d.ts +1 -0
- package/dist/cli/logo.js +20 -0
- package/dist/cli/setup.d.ts +2 -0
- package/dist/cli/setup.js +210 -0
- package/dist/cli/ux.d.ts +38 -0
- package/dist/cli/ux.js +104 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +66 -0
- package/dist/currency.d.ts +6 -0
- package/dist/currency.js +19 -0
- package/dist/db/connection.d.ts +5 -0
- package/dist/db/connection.js +45 -0
- package/dist/db/encryption.d.ts +11 -0
- package/dist/db/encryption.js +45 -0
- package/dist/db/helpers.d.ts +16 -0
- package/dist/db/helpers.js +45 -0
- package/dist/db/queries/account_balance.d.ts +61 -0
- package/dist/db/queries/account_balance.js +146 -0
- package/dist/db/queries/journal.d.ts +95 -0
- package/dist/db/queries/journal.js +204 -0
- package/dist/db/queries/search.d.ts +7 -0
- package/dist/db/queries/search.js +19 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +95 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/parser/pdf.d.ts +14 -0
- package/dist/parser/pdf.js +40 -0
- package/dist/parser/pipeline.d.ts +44 -0
- package/dist/parser/pipeline.js +160 -0
- package/dist/parser/prompts.d.ts +8 -0
- package/dist/parser/prompts.js +20 -0
- package/dist/parser/walker.d.ts +8 -0
- package/dist/parser/walker.js +42 -0
- package/dist/reconciler/pipeline.d.ts +17 -0
- package/dist/reconciler/pipeline.js +45 -0
- package/dist/reconciler/prompts.d.ts +12 -0
- package/dist/reconciler/prompts.js +22 -0
- package/dist/scanner/password-store.d.ts +34 -0
- package/dist/scanner/password-store.js +83 -0
- package/dist/scanner/pdf-unlock.d.ts +17 -0
- package/dist/scanner/pdf-unlock.js +48 -0
- package/dist/scanner/pdf.d.ts +17 -0
- package/dist/scanner/pdf.js +36 -0
- package/dist/scanner/pipeline.d.ts +32 -0
- package/dist/scanner/pipeline.js +137 -0
- package/dist/scanner/prompts.d.ts +8 -0
- package/dist/scanner/prompts.js +20 -0
- package/dist/scanner/state-machine.d.ts +60 -0
- package/dist/scanner/state-machine.js +64 -0
- package/dist/scanner/unlock.d.ts +24 -0
- package/dist/scanner/unlock.js +122 -0
- package/dist/scanner/walker.d.ts +8 -0
- package/dist/scanner/walker.js +42 -0
- package/package.json +65 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ScanFileResult {
|
|
2
|
+
fileId: string | null;
|
|
3
|
+
status: "scanned" | "needs_input" | "failed" | "skipped" | "replaced";
|
|
4
|
+
summary?: string;
|
|
5
|
+
error?: string;
|
|
6
|
+
pendingQuestions: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ScanOptions {
|
|
9
|
+
interactive?: boolean;
|
|
10
|
+
force?: boolean;
|
|
11
|
+
onProgress?: (msg: string) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function scanFile(filePath: string, opts?: ScanOptions): Promise<ScanFileResult>;
|
|
14
|
+
export interface ScanSummary {
|
|
15
|
+
total: number;
|
|
16
|
+
scanned: number;
|
|
17
|
+
replaced: number;
|
|
18
|
+
skipped: number;
|
|
19
|
+
needsInput: number;
|
|
20
|
+
failed: number;
|
|
21
|
+
details: {
|
|
22
|
+
name: string;
|
|
23
|
+
relPath: string;
|
|
24
|
+
result: ScanFileResult;
|
|
25
|
+
}[];
|
|
26
|
+
}
|
|
27
|
+
export interface RunScanOptions extends ScanOptions {
|
|
28
|
+
/** Optional regex (string). Partial, case-insensitive, against the relative path. */
|
|
29
|
+
regex?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function compileMatcher(input: string): RegExp;
|
|
32
|
+
export declare function runScan(opts?: RunScanOptions): Promise<ScanSummary>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { getDb } from "../db/connection.js";
|
|
3
|
+
import { runScanAgent } from "../ai/agent.js";
|
|
4
|
+
import { statusSpinner, makePromptUser, makeAgentOnProgress, } from "../cli/ux.js";
|
|
5
|
+
import { readPdf, buildDocumentBlock } from "./pdf.js";
|
|
6
|
+
import { buildScanUserMessage } from "./prompts.js";
|
|
7
|
+
import { scanDataDir } from "./walker.js";
|
|
8
|
+
import { unlockIfNeeded, persistUnlockOutcome } from "./unlock.js";
|
|
9
|
+
// ── DB helpers ──────────────────────────────────────────────────────────────
|
|
10
|
+
function findScannedByHash(db, hash) {
|
|
11
|
+
return db
|
|
12
|
+
.prepare(`SELECT id FROM scanned_files WHERE file_hash = ?`)
|
|
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);
|
|
34
|
+
}
|
|
35
|
+
// ── Per-file scan ───────────────────────────────────────────────────────────
|
|
36
|
+
export async function scanFile(filePath, opts = {}) {
|
|
37
|
+
const db = getDb();
|
|
38
|
+
const file = readPdf(filePath);
|
|
39
|
+
const existing = findScannedByHash(db, file.hash);
|
|
40
|
+
if (existing && !opts.force) {
|
|
41
|
+
return { fileId: existing.id, status: "skipped", pendingQuestions: countPendingQuestions(db, existing.id) };
|
|
42
|
+
}
|
|
43
|
+
const wasReplaced = !!existing;
|
|
44
|
+
if (existing) {
|
|
45
|
+
deleteScannedFile(db, existing.id);
|
|
46
|
+
}
|
|
47
|
+
let unlocked;
|
|
48
|
+
try {
|
|
49
|
+
unlocked = await unlockIfNeeded({
|
|
50
|
+
db,
|
|
51
|
+
filePath,
|
|
52
|
+
bytes: file.bytes,
|
|
53
|
+
interactive: opts.interactive ?? true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return { fileId: null, status: "failed", error: err.message, pendingQuestions: 0 };
|
|
58
|
+
}
|
|
59
|
+
persistUnlockOutcome(db, filePath, unlocked.outcome);
|
|
60
|
+
const fileId = insertScannedFile(db, { path: filePath, hash: file.hash, mime: file.mime });
|
|
61
|
+
const block = buildDocumentBlock(unlocked.decrypted, file.fileName, file.mime);
|
|
62
|
+
const messages = [
|
|
63
|
+
{
|
|
64
|
+
role: "user",
|
|
65
|
+
content: [
|
|
66
|
+
block,
|
|
67
|
+
{ type: "text", text: buildScanUserMessage({ fileName: file.fileName }) },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
const spinner = statusSpinner(`Scanning ${file.fileName}...`);
|
|
72
|
+
let summary = "";
|
|
73
|
+
try {
|
|
74
|
+
const text = await runScanAgent({
|
|
75
|
+
db,
|
|
76
|
+
initialMessages: messages,
|
|
77
|
+
prompt: { fileName: file.fileName },
|
|
78
|
+
agentCtx: {
|
|
79
|
+
fileId,
|
|
80
|
+
interactive: opts.interactive ?? true,
|
|
81
|
+
promptUser: opts.interactive === false ? undefined : makePromptUser(spinner),
|
|
82
|
+
onComplete: (s) => { summary = s; },
|
|
83
|
+
},
|
|
84
|
+
onProgress: makeAgentOnProgress(spinner, file.fileName),
|
|
85
|
+
});
|
|
86
|
+
const stillPending = countPendingQuestions(db, fileId);
|
|
87
|
+
if (stillPending > 0) {
|
|
88
|
+
setFileStatus(db, fileId, "needs_input", { raw_text: text });
|
|
89
|
+
spinner.info(`${file.fileName} needs input (${stillPending} pending).`);
|
|
90
|
+
return { fileId, status: "needs_input", summary: summary || text, pendingQuestions: stillPending };
|
|
91
|
+
}
|
|
92
|
+
setFileStatus(db, fileId, "scanned", { raw_text: text });
|
|
93
|
+
spinner.succeed(`Scanned ${file.fileName}.`);
|
|
94
|
+
return {
|
|
95
|
+
fileId,
|
|
96
|
+
status: wasReplaced ? "replaced" : "scanned",
|
|
97
|
+
summary: summary || text,
|
|
98
|
+
pendingQuestions: 0,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
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
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function compileMatcher(input) {
|
|
108
|
+
return new RegExp(input, "i");
|
|
109
|
+
}
|
|
110
|
+
export async function runScan(opts = {}) {
|
|
111
|
+
const matcher = opts.regex ? compileMatcher(opts.regex) : null;
|
|
112
|
+
const files = scanDataDir().filter(f => (matcher ? matcher.test(f.relPath) : true));
|
|
113
|
+
const summary = {
|
|
114
|
+
total: files.length,
|
|
115
|
+
scanned: 0,
|
|
116
|
+
replaced: 0,
|
|
117
|
+
skipped: 0,
|
|
118
|
+
needsInput: 0,
|
|
119
|
+
failed: 0,
|
|
120
|
+
details: [],
|
|
121
|
+
};
|
|
122
|
+
for (const f of files) {
|
|
123
|
+
const result = await scanFile(f.path, opts);
|
|
124
|
+
summary.details.push({ name: f.name, relPath: f.relPath, result });
|
|
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++;
|
|
135
|
+
}
|
|
136
|
+
return summary;
|
|
137
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-message prelude that accompanies the PDF document block. The persona
|
|
3
|
+
* and rules live in the scan system prompt (src/ai/system-prompt.ts); this
|
|
4
|
+
* message is a per-file instruction.
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildScanUserMessage(opts: {
|
|
7
|
+
fileName: string;
|
|
8
|
+
}): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-message prelude that accompanies the PDF document block. The persona
|
|
3
|
+
* and rules live in the scan system prompt (src/ai/system-prompt.ts); this
|
|
4
|
+
* message is a per-file instruction.
|
|
5
|
+
*/
|
|
6
|
+
export function buildScanUserMessage(opts) {
|
|
7
|
+
return [
|
|
8
|
+
`Please scan the attached document.`,
|
|
9
|
+
`File: ${opts.fileName}`,
|
|
10
|
+
``,
|
|
11
|
+
`Steps:`,
|
|
12
|
+
`1. Call list_accounts to see what already exists.`,
|
|
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.`,
|
|
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 record_journal_entry with balanced debit/credit lines. Use existing accounts where possible; create expense/income accounts as needed.`,
|
|
17
|
+
`6. If a row is ambiguous, call ask_user before guessing.`,
|
|
18
|
+
`7. When you are done, call mark_file_scanned with a short summary.`,
|
|
19
|
+
].join("\n");
|
|
20
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { StoredPassword } from "./password-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pure state machine for the unlock phase of a single file scan. Side effects
|
|
4
|
+
* (mupdf calls, prompts, DB reads) live in the orchestrator; this module only
|
|
5
|
+
* encodes the transition logic so it can be exhaustively unit-tested.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MAX_PASSWORD_ATTEMPTS = 3;
|
|
8
|
+
export type UnlockOutcome = {
|
|
9
|
+
kind: "plaintext";
|
|
10
|
+
} | {
|
|
11
|
+
kind: "from-store";
|
|
12
|
+
storedId: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "from-user";
|
|
15
|
+
password: string;
|
|
16
|
+
};
|
|
17
|
+
export type UnlockState = {
|
|
18
|
+
kind: "init";
|
|
19
|
+
} | {
|
|
20
|
+
kind: "trying-stored";
|
|
21
|
+
candidates: StoredPassword[];
|
|
22
|
+
} | {
|
|
23
|
+
kind: "awaiting-user";
|
|
24
|
+
attempt: number;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "done";
|
|
27
|
+
decrypted: Buffer;
|
|
28
|
+
outcome: UnlockOutcome;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "failed";
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
export type UnlockEvent = {
|
|
34
|
+
kind: "INSPECTED_PLAINTEXT";
|
|
35
|
+
bytes: Buffer;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "INSPECTED_ENCRYPTED";
|
|
38
|
+
candidates: StoredPassword[];
|
|
39
|
+
} | {
|
|
40
|
+
kind: "STORED_UNLOCK_OK";
|
|
41
|
+
decrypted: Buffer;
|
|
42
|
+
usedStoredId: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "STORED_UNLOCK_EXHAUSTED";
|
|
45
|
+
} | {
|
|
46
|
+
kind: "USER_CANCELLED";
|
|
47
|
+
} | {
|
|
48
|
+
kind: "UNLOCK_OK";
|
|
49
|
+
decrypted: Buffer;
|
|
50
|
+
password: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: "UNLOCK_FAIL";
|
|
53
|
+
};
|
|
54
|
+
export declare function isTerminal(state: UnlockState): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Pure transition. Throws if the event doesn't make sense for the current state;
|
|
57
|
+
* the orchestrator never produces such combinations, so reaching the throw is a
|
|
58
|
+
* programmer error worth surfacing loudly.
|
|
59
|
+
*/
|
|
60
|
+
export declare function transition(state: UnlockState, event: UnlockEvent): UnlockState;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state machine for the unlock phase of a single file scan. Side effects
|
|
3
|
+
* (mupdf calls, prompts, DB reads) live in the orchestrator; this module only
|
|
4
|
+
* encodes the transition logic so it can be exhaustively unit-tested.
|
|
5
|
+
*/
|
|
6
|
+
export const MAX_PASSWORD_ATTEMPTS = 3;
|
|
7
|
+
export function isTerminal(state) {
|
|
8
|
+
return state.kind === "done" || state.kind === "failed";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Pure transition. Throws if the event doesn't make sense for the current state;
|
|
12
|
+
* the orchestrator never produces such combinations, so reaching the throw is a
|
|
13
|
+
* programmer error worth surfacing loudly.
|
|
14
|
+
*/
|
|
15
|
+
export function transition(state, event) {
|
|
16
|
+
switch (state.kind) {
|
|
17
|
+
case "init":
|
|
18
|
+
if (event.kind === "INSPECTED_PLAINTEXT") {
|
|
19
|
+
return { kind: "done", decrypted: event.bytes, outcome: { kind: "plaintext" } };
|
|
20
|
+
}
|
|
21
|
+
if (event.kind === "INSPECTED_ENCRYPTED") {
|
|
22
|
+
return { kind: "trying-stored", candidates: event.candidates };
|
|
23
|
+
}
|
|
24
|
+
break;
|
|
25
|
+
case "trying-stored":
|
|
26
|
+
if (event.kind === "STORED_UNLOCK_OK") {
|
|
27
|
+
return {
|
|
28
|
+
kind: "done",
|
|
29
|
+
decrypted: event.decrypted,
|
|
30
|
+
outcome: { kind: "from-store", storedId: event.usedStoredId },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (event.kind === "STORED_UNLOCK_EXHAUSTED") {
|
|
34
|
+
return { kind: "awaiting-user", attempt: 1 };
|
|
35
|
+
}
|
|
36
|
+
break;
|
|
37
|
+
case "awaiting-user":
|
|
38
|
+
if (event.kind === "USER_CANCELLED") {
|
|
39
|
+
return { kind: "failed", reason: "password required" };
|
|
40
|
+
}
|
|
41
|
+
if (event.kind === "UNLOCK_OK") {
|
|
42
|
+
return {
|
|
43
|
+
kind: "done",
|
|
44
|
+
decrypted: event.decrypted,
|
|
45
|
+
outcome: { kind: "from-user", password: event.password },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (event.kind === "UNLOCK_FAIL") {
|
|
49
|
+
if (state.attempt >= MAX_PASSWORD_ATTEMPTS) {
|
|
50
|
+
return {
|
|
51
|
+
kind: "failed",
|
|
52
|
+
reason: `incorrect password after ${MAX_PASSWORD_ATTEMPTS} attempts`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { kind: "awaiting-user", attempt: state.attempt + 1 };
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
case "done":
|
|
59
|
+
case "failed":
|
|
60
|
+
// Terminal — no further transitions.
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`Invalid unlock transition: ${state.kind} + ${event.kind}`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import { type UnlockOutcome } from "./state-machine.js";
|
|
3
|
+
export interface UnlockCtx {
|
|
4
|
+
db: Database.Database;
|
|
5
|
+
filePath: string;
|
|
6
|
+
bytes: Buffer;
|
|
7
|
+
interactive: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Drive the pure unlock state machine to a terminal state, returning the
|
|
11
|
+
* decrypted bytes and the outcome (plaintext / from-store / from-user) so the
|
|
12
|
+
* caller can persist passwords or record stored-key usage.
|
|
13
|
+
*/
|
|
14
|
+
export declare function unlockIfNeeded(ctx: UnlockCtx): Promise<{
|
|
15
|
+
decrypted: Buffer;
|
|
16
|
+
outcome: UnlockOutcome;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* After a successful unlock, persist the outcome:
|
|
20
|
+
* from-store → bump usage counter on the stored password
|
|
21
|
+
* from-user → save the new password under a filename-pattern key
|
|
22
|
+
* plaintext → no-op
|
|
23
|
+
*/
|
|
24
|
+
export declare function persistUnlockOutcome(db: Database.Database, filePath: string, outcome: UnlockOutcome): void;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import inquirer from "inquirer";
|
|
2
|
+
import { basename } from "path";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
import { statusSpinner } from "../cli/ux.js";
|
|
5
|
+
import { findCandidates, savePassword, recordUse, suggestPattern, } from "./password-store.js";
|
|
6
|
+
import { transition, isTerminal, } from "./state-machine.js";
|
|
7
|
+
import { isEncrypted, unlock } from "./pdf-unlock.js";
|
|
8
|
+
/**
|
|
9
|
+
* Drive the pure unlock state machine to a terminal state, returning the
|
|
10
|
+
* decrypted bytes and the outcome (plaintext / from-store / from-user) so the
|
|
11
|
+
* caller can persist passwords or record stored-key usage.
|
|
12
|
+
*/
|
|
13
|
+
export async function unlockIfNeeded(ctx) {
|
|
14
|
+
let state = { kind: "init" };
|
|
15
|
+
while (!isTerminal(state)) {
|
|
16
|
+
const event = await stepUnlock(state, ctx);
|
|
17
|
+
state = transition(state, event);
|
|
18
|
+
}
|
|
19
|
+
if (state.kind === "failed") {
|
|
20
|
+
throw new Error(state.reason);
|
|
21
|
+
}
|
|
22
|
+
if (state.kind !== "done") {
|
|
23
|
+
throw new Error(`unlock loop exited in non-terminal state ${state.kind}`);
|
|
24
|
+
}
|
|
25
|
+
return { decrypted: state.decrypted, outcome: state.outcome };
|
|
26
|
+
}
|
|
27
|
+
async function stepUnlock(state, ctx) {
|
|
28
|
+
switch (state.kind) {
|
|
29
|
+
case "init": {
|
|
30
|
+
const spinner = statusSpinner(`Inspecting ${basename(ctx.filePath)}...`);
|
|
31
|
+
try {
|
|
32
|
+
const encrypted = await isEncrypted(ctx.bytes);
|
|
33
|
+
if (!encrypted) {
|
|
34
|
+
spinner.succeed(`${basename(ctx.filePath)} is not encrypted.`);
|
|
35
|
+
return { kind: "INSPECTED_PLAINTEXT", bytes: ctx.bytes };
|
|
36
|
+
}
|
|
37
|
+
const candidates = findCandidates(ctx.db, ctx.filePath, config.dbEncryptionKey);
|
|
38
|
+
spinner.info(`${basename(ctx.filePath)} is encrypted (${candidates.length} saved password${candidates.length === 1 ? "" : "s"} match).`);
|
|
39
|
+
return { kind: "INSPECTED_ENCRYPTED", candidates };
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
spinner.fail("Inspection failed.");
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
case "trying-stored":
|
|
47
|
+
return await tryStoredPasswords(ctx.bytes, state.candidates);
|
|
48
|
+
case "awaiting-user": {
|
|
49
|
+
if (!ctx.interactive) {
|
|
50
|
+
return { kind: "USER_CANCELLED" };
|
|
51
|
+
}
|
|
52
|
+
const password = await promptForPassword(basename(ctx.filePath), state.attempt);
|
|
53
|
+
if (!password) {
|
|
54
|
+
return { kind: "USER_CANCELLED" };
|
|
55
|
+
}
|
|
56
|
+
const spinner = statusSpinner("Decrypting...");
|
|
57
|
+
const result = await unlock(ctx.bytes, password);
|
|
58
|
+
if (result.ok && result.decrypted) {
|
|
59
|
+
spinner.succeed("Decrypted.");
|
|
60
|
+
return { kind: "UNLOCK_OK", decrypted: result.decrypted, password };
|
|
61
|
+
}
|
|
62
|
+
spinner.fail(`Incorrect password (attempt ${state.attempt}/3).`);
|
|
63
|
+
return { kind: "UNLOCK_FAIL" };
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
throw new Error(`stepUnlock called with terminal state ${state.kind}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function tryStoredPasswords(bytes, candidates) {
|
|
70
|
+
if (candidates.length === 0) {
|
|
71
|
+
return { kind: "STORED_UNLOCK_EXHAUSTED" };
|
|
72
|
+
}
|
|
73
|
+
const spinner = statusSpinner(`Trying saved password 1/${candidates.length}...`);
|
|
74
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
75
|
+
const cand = candidates[i];
|
|
76
|
+
spinner.text = `Trying saved password ${i + 1}/${candidates.length} (pattern ${cand.pattern})`;
|
|
77
|
+
const result = await unlock(bytes, cand.password);
|
|
78
|
+
if (result.ok && result.decrypted) {
|
|
79
|
+
spinner.succeed(`Unlocked with saved password (pattern ${cand.pattern}).`);
|
|
80
|
+
return {
|
|
81
|
+
kind: "STORED_UNLOCK_OK",
|
|
82
|
+
decrypted: result.decrypted,
|
|
83
|
+
usedStoredId: cand.id,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
spinner.info("No saved password matched. Asking the user.");
|
|
88
|
+
return { kind: "STORED_UNLOCK_EXHAUSTED" };
|
|
89
|
+
}
|
|
90
|
+
async function promptForPassword(fileName, attempt) {
|
|
91
|
+
const message = attempt === 1
|
|
92
|
+
? `This PDF is encrypted. Password for ${fileName}:`
|
|
93
|
+
: `Password for ${fileName} (attempt ${attempt}/3):`;
|
|
94
|
+
const { password } = await inquirer.prompt([
|
|
95
|
+
{ type: "password", name: "password", mask: "*", message },
|
|
96
|
+
]);
|
|
97
|
+
return String(password ?? "").trim();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* After a successful unlock, persist the outcome:
|
|
101
|
+
* from-store → bump usage counter on the stored password
|
|
102
|
+
* from-user → save the new password under a filename-pattern key
|
|
103
|
+
* plaintext → no-op
|
|
104
|
+
*/
|
|
105
|
+
export function persistUnlockOutcome(db, filePath, outcome) {
|
|
106
|
+
if (outcome.kind === "from-store") {
|
|
107
|
+
recordUse(db, outcome.storedId);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (outcome.kind === "from-user") {
|
|
111
|
+
const pattern = suggestPattern(filePath);
|
|
112
|
+
const spinner = statusSpinner(`Saving password for pattern ${pattern}...`);
|
|
113
|
+
try {
|
|
114
|
+
savePassword(db, pattern, outcome.password, config.dbEncryptionKey);
|
|
115
|
+
spinner.succeed(`Saved password for pattern ${pattern} in secure vault.`);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
spinner.fail(`Could not save password: ${err.message}`);
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface ScannedFile {
|
|
2
|
+
path: string;
|
|
3
|
+
name: string;
|
|
4
|
+
/** Path relative to the data dir, forward-slashed. */
|
|
5
|
+
relPath: string;
|
|
6
|
+
}
|
|
7
|
+
/** Walk the data directory recursively and return every supported file found. */
|
|
8
|
+
export declare function scanDataDir(): ScannedFile[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "fs";
|
|
2
|
+
import { resolve, basename, relative, sep } from "path";
|
|
3
|
+
import { getDataDir } from "../config.js";
|
|
4
|
+
const SUPPORTED_EXTS = new Set([".pdf"]);
|
|
5
|
+
function walk(dir, root, out) {
|
|
6
|
+
let entries;
|
|
7
|
+
try {
|
|
8
|
+
entries = readdirSync(dir);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (entry.startsWith("."))
|
|
15
|
+
continue;
|
|
16
|
+
const full = resolve(dir, entry);
|
|
17
|
+
let s;
|
|
18
|
+
try {
|
|
19
|
+
s = statSync(full);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (s.isDirectory()) {
|
|
25
|
+
walk(full, root, out);
|
|
26
|
+
}
|
|
27
|
+
else if (s.isFile()) {
|
|
28
|
+
const ext = entry.slice(entry.lastIndexOf(".")).toLowerCase();
|
|
29
|
+
if (!SUPPORTED_EXTS.has(ext))
|
|
30
|
+
continue;
|
|
31
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
32
|
+
out.push({ path: full, name: basename(full), relPath: rel });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Walk the data directory recursively and return every supported file found. */
|
|
37
|
+
export function scanDataDir() {
|
|
38
|
+
const out = [];
|
|
39
|
+
const root = getDataDir();
|
|
40
|
+
walk(root, root, out);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plasalid",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The local-first data layer for personal finance.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"finance",
|
|
7
|
+
"personal-finance",
|
|
8
|
+
"aggregator",
|
|
9
|
+
"parser",
|
|
10
|
+
"cli",
|
|
11
|
+
"ai",
|
|
12
|
+
"local"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/phureewat29/plasalid#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/phureewat29/plasalid/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/phureewat29/plasalid.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
23
|
+
"author": "Phureewat A",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "index.js",
|
|
26
|
+
"bin": {
|
|
27
|
+
"plasalid": "dist/cli/index.js"
|
|
28
|
+
},
|
|
29
|
+
"directories": {
|
|
30
|
+
"example": "examples"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist/"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"dev": "tsx src/cli/index.ts",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@anthropic-ai/sdk": "^0.74.0",
|
|
43
|
+
"chalk": "^5.3.0",
|
|
44
|
+
"commander": "^13.0.0",
|
|
45
|
+
"dotenv": "^16.4.0",
|
|
46
|
+
"ink": "^5.2.1",
|
|
47
|
+
"ink-spinner": "^5.0.0",
|
|
48
|
+
"inquirer": "^12.0.0",
|
|
49
|
+
"libsql": "^0.5.0",
|
|
50
|
+
"mupdf": "^1.27.0",
|
|
51
|
+
"openai": "^6.37.0",
|
|
52
|
+
"ora": "^8.0.0",
|
|
53
|
+
"react": "^18.3.1"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^22.0.0",
|
|
57
|
+
"@types/react": "^18.3.28",
|
|
58
|
+
"tsx": "^4.7.0",
|
|
59
|
+
"typescript": "^5.5.0",
|
|
60
|
+
"vitest": "^3.2.4"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18"
|
|
64
|
+
}
|
|
65
|
+
}
|