plasalid 0.8.3 → 0.9.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 +4 -0
- package/dist/ai/personas.js +29 -6
- package/dist/ai/prompt-sections.d.ts +10 -0
- package/dist/ai/prompt-sections.js +29 -0
- package/dist/ai/system-prompt.js +10 -6
- package/dist/ai/tools/clarify.js +35 -0
- package/dist/ai/tools/common.js +3 -2
- package/dist/ai/tools/index.js +6 -3
- package/dist/ai/tools/ingest.js +47 -35
- package/dist/ai/tools/mutate.d.ts +2 -0
- package/dist/ai/tools/mutate.js +81 -0
- package/dist/cli/commands/files.d.ts +7 -0
- package/dist/cli/commands/files.js +24 -0
- package/dist/cli/commands/rules.js +23 -20
- package/dist/cli/commands/scan.js +8 -3
- package/dist/cli/helper.d.ts +9 -1
- package/dist/cli/helper.js +17 -2
- package/dist/cli/index.js +12 -0
- package/dist/cli/ink/FilesBrowser.d.ts +7 -0
- package/dist/cli/ink/FilesBrowser.js +103 -0
- package/dist/cli/ink/ListBrowser.d.ts +9 -1
- package/dist/cli/ink/ListBrowser.js +2 -2
- package/dist/cli/ink/PromptFrame.js +1 -1
- package/dist/cli/ink/ScanDashboard.js +90 -65
- package/dist/cli/ink/hooks/useFooterText.js +14 -22
- package/dist/db/queries/files.d.ts +29 -0
- package/dist/db/queries/files.js +34 -0
- package/dist/db/queries/questions.d.ts +17 -0
- package/dist/db/queries/questions.js +47 -9
- package/dist/db/queries/rules.d.ts +31 -0
- package/dist/db/queries/rules.js +55 -0
- package/dist/db/queries/transactions.d.ts +34 -0
- package/dist/db/queries/transactions.js +86 -0
- package/dist/db/schema.js +17 -0
- package/dist/scanner/clarifier-memory.d.ts +15 -3
- package/dist/scanner/clarifier-memory.js +38 -17
- package/dist/scanner/clarifier.d.ts +2 -1
- package/dist/scanner/clarifier.js +40 -26
- package/dist/scanner/commit-pipeline.d.ts +56 -0
- package/dist/scanner/commit-pipeline.js +204 -0
- package/dist/scanner/committer.d.ts +56 -0
- package/dist/scanner/committer.js +204 -0
- package/dist/scanner/parse.js +25 -7
- package/dist/scanner/recurrence-pipeline.d.ts +28 -0
- package/dist/scanner/recurrence-pipeline.js +126 -0
- package/dist/scanner/recurrence.d.ts +28 -0
- package/dist/scanner/recurrence.js +155 -0
- package/dist/scanner/rule-keys.d.ts +13 -0
- package/dist/scanner/rule-keys.js +28 -0
- package/dist/scanner/rules.d.ts +13 -0
- package/dist/scanner/rules.js +28 -0
- package/package.json +1 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
/**
|
|
3
|
+
* Structural key for a recurring-payment bucket. Same key across runs means
|
|
4
|
+
* the same (account, amount, currency, side) signature — the unit on which
|
|
5
|
+
* we learn "yes this recurs" / "no this doesn't" decisions.
|
|
6
|
+
*
|
|
7
|
+
* Embeds amount-in-cents because the recurrence identity *is* the amount:
|
|
8
|
+
* ฿199 monthly ≠ ฿299 monthly. The "no amounts in dedup keys" rule applies
|
|
9
|
+
* to merchant-category rules where amount varies; here it is intrinsic.
|
|
10
|
+
*/
|
|
11
|
+
export declare function recurrenceCandidateKey(accountId: string, amountCents: number, currency: string, side: "debit" | "credit"): string;
|
|
12
|
+
/**
|
|
13
|
+
* Fast path. For every learned "Link as recurring" rule, attach any matching
|
|
14
|
+
* unlinked transaction to the existing recurrences row. One rules-table
|
|
15
|
+
* lookup and one recurrences-table lookup per `(account, currency, amount)`
|
|
16
|
+
* bucket — never re-runs the heuristic.
|
|
17
|
+
*/
|
|
18
|
+
export declare function applyRecurrenceRules(db: Database.Database): {
|
|
19
|
+
linked: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Slow path. Runs the heuristic, drops irregular cadences, and skips any
|
|
23
|
+
* bucket already covered by a rule (either decision — "Link" or "Not
|
|
24
|
+
* recurring" both mean "don't ask again") or by an already-open question
|
|
25
|
+
* with the same key. Each survivor becomes one `recurrence_candidate`
|
|
26
|
+
* question that flows through the existing clarifier pipeline.
|
|
27
|
+
*/
|
|
28
|
+
export declare function generateRecurrenceCandidateQuestions(db: Database.Database, scanId: string | null): number;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { findRecurrenceCandidates, linkTransactionToRecurrence, } from "../db/queries/recurrences.js";
|
|
2
|
+
import { recordQuestion } from "../db/queries/questions.js";
|
|
3
|
+
import { formatAmount } from "../currency.js";
|
|
4
|
+
/**
|
|
5
|
+
* Structural key for a recurring-payment bucket. Same key across runs means
|
|
6
|
+
* the same (account, amount, currency, side) signature — the unit on which
|
|
7
|
+
* we learn "yes this recurs" / "no this doesn't" decisions.
|
|
8
|
+
*
|
|
9
|
+
* Embeds amount-in-cents because the recurrence identity *is* the amount:
|
|
10
|
+
* ฿199 monthly ≠ ฿299 monthly. The "no amounts in dedup keys" rule applies
|
|
11
|
+
* to merchant-category rules where amount varies; here it is intrinsic.
|
|
12
|
+
*/
|
|
13
|
+
export function recurrenceCandidateKey(accountId, amountCents, currency, side) {
|
|
14
|
+
return `recurrence:${accountId}:${currency}:${amountCents}:${side}`;
|
|
15
|
+
}
|
|
16
|
+
const RULE_KIND = "recurrence_candidate";
|
|
17
|
+
const ANSWER_LINK = "Link as recurring";
|
|
18
|
+
/**
|
|
19
|
+
* Fast path. For every learned "Link as recurring" rule, attach any matching
|
|
20
|
+
* unlinked transaction to the existing recurrences row. One rules-table
|
|
21
|
+
* lookup and one recurrences-table lookup per `(account, currency, amount)`
|
|
22
|
+
* bucket — never re-runs the heuristic.
|
|
23
|
+
*/
|
|
24
|
+
export function applyRecurrenceRules(db) {
|
|
25
|
+
const rules = db.prepare(`SELECT key FROM rules WHERE kind = ? AND target = ?`).all(RULE_KIND, ANSWER_LINK);
|
|
26
|
+
if (rules.length === 0)
|
|
27
|
+
return { linked: 0 };
|
|
28
|
+
const unlinkedByKey = new Map();
|
|
29
|
+
const rows = db.prepare(`SELECT p.transaction_id,
|
|
30
|
+
p.account_id,
|
|
31
|
+
p.currency,
|
|
32
|
+
CASE WHEN p.debit > 0 THEN p.debit ELSE p.credit END AS amount,
|
|
33
|
+
CASE WHEN p.debit > 0 THEN 'debit' ELSE 'credit' END AS side
|
|
34
|
+
FROM postings p
|
|
35
|
+
JOIN transactions t ON t.id = p.transaction_id
|
|
36
|
+
WHERE t.recurrence_id IS NULL
|
|
37
|
+
AND (p.debit > 0 OR p.credit > 0)`).all();
|
|
38
|
+
for (const r of rows) {
|
|
39
|
+
const key = recurrenceCandidateKey(r.account_id, Math.round(r.amount * 100), r.currency, r.side);
|
|
40
|
+
const bucket = unlinkedByKey.get(key) ?? [];
|
|
41
|
+
bucket.push(r);
|
|
42
|
+
unlinkedByKey.set(key, bucket);
|
|
43
|
+
}
|
|
44
|
+
let linked = 0;
|
|
45
|
+
for (const { key } of rules) {
|
|
46
|
+
const bucket = unlinkedByKey.get(key);
|
|
47
|
+
if (!bucket || bucket.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
const first = bucket[0];
|
|
50
|
+
const recurrence = db.prepare(`SELECT id FROM recurrences WHERE account_id = ? AND currency = ? AND amount_typical = ? LIMIT 1`).get(first.account_id, first.currency, Math.round(first.amount * 100) / 100);
|
|
51
|
+
if (!recurrence)
|
|
52
|
+
continue; // rule learned but aggregate row gone — let the heuristic re-surface
|
|
53
|
+
for (const r of bucket) {
|
|
54
|
+
linkTransactionToRecurrence(db, r.transaction_id, recurrence.id);
|
|
55
|
+
linked++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { linked };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Slow path. Runs the heuristic, drops irregular cadences, and skips any
|
|
62
|
+
* bucket already covered by a rule (either decision — "Link" or "Not
|
|
63
|
+
* recurring" both mean "don't ask again") or by an already-open question
|
|
64
|
+
* with the same key. Each survivor becomes one `recurrence_candidate`
|
|
65
|
+
* question that flows through the existing clarifier pipeline.
|
|
66
|
+
*/
|
|
67
|
+
export function generateRecurrenceCandidateQuestions(db, scanId) {
|
|
68
|
+
const coveredKeys = collectCoveredKeys(db);
|
|
69
|
+
const raw = findRecurrenceCandidates(db).filter((c) => c.implied_frequency !== "irregular");
|
|
70
|
+
const candidates = dedupeByTransactionSet(raw);
|
|
71
|
+
let created = 0;
|
|
72
|
+
for (const c of candidates) {
|
|
73
|
+
const amountCents = Math.round(c.amount * 100);
|
|
74
|
+
const side = c.side;
|
|
75
|
+
const key = recurrenceCandidateKey(c.account_id, amountCents, c.currency, side);
|
|
76
|
+
if (coveredKeys.has(key))
|
|
77
|
+
continue;
|
|
78
|
+
recordQuestion(db, {
|
|
79
|
+
transaction_id: null,
|
|
80
|
+
account_id: c.account_id,
|
|
81
|
+
file_id: null,
|
|
82
|
+
scan_id: scanId,
|
|
83
|
+
kind: RULE_KIND,
|
|
84
|
+
prompt: buildPrompt(c),
|
|
85
|
+
options: ["Link as recurring", "Not recurring", "Skip"],
|
|
86
|
+
context: {
|
|
87
|
+
rule_key: key,
|
|
88
|
+
account_id: c.account_id,
|
|
89
|
+
amount: c.amount,
|
|
90
|
+
currency: c.currency,
|
|
91
|
+
side: c.side,
|
|
92
|
+
transaction_ids: c.transactions.map((t) => t.id),
|
|
93
|
+
median_days_between: c.median_days_between,
|
|
94
|
+
implied_frequency: c.implied_frequency,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
coveredKeys.add(key); // avoid duplicate inserts within this same call
|
|
98
|
+
created++;
|
|
99
|
+
}
|
|
100
|
+
return created;
|
|
101
|
+
}
|
|
102
|
+
function collectCoveredKeys(db) {
|
|
103
|
+
const ruleKeys = db.prepare(`SELECT key FROM rules WHERE kind = ?`).all(RULE_KIND);
|
|
104
|
+
const openQuestions = db.prepare(`SELECT context_json FROM questions WHERE kind = ?`).all(RULE_KIND);
|
|
105
|
+
const keys = new Set(ruleKeys.map((r) => r.key));
|
|
106
|
+
for (const q of openQuestions) {
|
|
107
|
+
if (!q.context_json)
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(q.context_json);
|
|
111
|
+
if (typeof parsed?.rule_key === "string")
|
|
112
|
+
keys.add(parsed.rule_key);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// malformed context — ignore; the question already exists, generation
|
|
116
|
+
// wouldn't dedupe it anyway, so the worst case is a duplicate.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return keys;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A single recurring event lands as two posting buckets — the expense/income
|
|
123
|
+
* leg and the asset/liability leg of the same N transactions. Collapse to
|
|
124
|
+
* one prompt per event, preferring the leg the user actually thinks about
|
|
125
|
+
* (expense > income > liability > asset > equity).
|
|
126
|
+
*/
|
|
127
|
+
function dedupeByTransactionSet(candidates) {
|
|
128
|
+
const byTxSig = new Map();
|
|
129
|
+
for (const c of candidates) {
|
|
130
|
+
const sig = c.transactions.map((t) => t.id).sort().join(",");
|
|
131
|
+
const arr = byTxSig.get(sig) ?? [];
|
|
132
|
+
arr.push(c);
|
|
133
|
+
byTxSig.set(sig, arr);
|
|
134
|
+
}
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const group of byTxSig.values()) {
|
|
137
|
+
group.sort((a, b) => typeRank(a.account_id) - typeRank(b.account_id) ||
|
|
138
|
+
a.account_id.localeCompare(b.account_id));
|
|
139
|
+
out.push(group[0]);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
const TYPE_PRIORITY = {
|
|
144
|
+
expense: 0, income: 1, liability: 2, asset: 3, equity: 4,
|
|
145
|
+
};
|
|
146
|
+
function typeRank(accountId) {
|
|
147
|
+
return TYPE_PRIORITY[accountId.split(":")[0]] ?? 99;
|
|
148
|
+
}
|
|
149
|
+
function buildPrompt(c) {
|
|
150
|
+
const amountStr = formatAmount(c.amount, c.currency);
|
|
151
|
+
const sideLabel = c.side === "debit" ? "outflow" : "inflow";
|
|
152
|
+
return (`${c.transactions.length} ${sideLabel}s on \`${c.account_id}\` of ${amountStr} ` +
|
|
153
|
+
`every ~${c.median_days_between} days (looks ${c.implied_frequency}). ` +
|
|
154
|
+
`Link them as a recurring item?`);
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical signatures used as the `key` half of `rules` rows. Derived
|
|
3
|
+
* from the structural context of a question (merchant id, raw descriptor,
|
|
4
|
+
* account pair) — never from prompt prose, because prose embeds volatile
|
|
5
|
+
* data like dates and amounts that would prevent the rule from matching the
|
|
6
|
+
* next time the same pattern appears.
|
|
7
|
+
*/
|
|
8
|
+
export type RuleKey = string;
|
|
9
|
+
export declare function normalizeDescriptor(raw: string): string;
|
|
10
|
+
export declare function merchantKey(merchantId: string): RuleKey;
|
|
11
|
+
export declare function descriptorKey(descriptor: string): RuleKey;
|
|
12
|
+
export declare function accountPairKey(a: string, b: string): RuleKey;
|
|
13
|
+
export declare function accountIdKey(id: string): RuleKey;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical signatures used as the `key` half of `rules` rows. Derived
|
|
3
|
+
* from the structural context of a question (merchant id, raw descriptor,
|
|
4
|
+
* account pair) — never from prompt prose, because prose embeds volatile
|
|
5
|
+
* data like dates and amounts that would prevent the rule from matching the
|
|
6
|
+
* next time the same pattern appears.
|
|
7
|
+
*/
|
|
8
|
+
const NON_WORD = /[^\p{L}\p{N}]+/gu;
|
|
9
|
+
export function normalizeDescriptor(raw) {
|
|
10
|
+
return raw
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(NON_WORD, " ")
|
|
13
|
+
.replace(/\s+/g, " ")
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
export function merchantKey(merchantId) {
|
|
17
|
+
return `merchant:${merchantId}`;
|
|
18
|
+
}
|
|
19
|
+
export function descriptorKey(descriptor) {
|
|
20
|
+
return `descriptor:${normalizeDescriptor(descriptor)}`;
|
|
21
|
+
}
|
|
22
|
+
export function accountPairKey(a, b) {
|
|
23
|
+
const [lo, hi] = [a, b].sort();
|
|
24
|
+
return `account-pair:${lo}|${hi}`;
|
|
25
|
+
}
|
|
26
|
+
export function accountIdKey(id) {
|
|
27
|
+
return `account:${id}`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical signatures used as the `key` half of `rules` rows. Derived
|
|
3
|
+
* from the structural context of a question (merchant id, raw descriptor,
|
|
4
|
+
* account pair) — never from prompt prose, because prose embeds volatile
|
|
5
|
+
* data like dates and amounts that would prevent the rule from matching the
|
|
6
|
+
* next time the same pattern appears.
|
|
7
|
+
*/
|
|
8
|
+
export type RuleKey = string;
|
|
9
|
+
export declare function normalizeDescriptor(raw: string): string;
|
|
10
|
+
export declare function merchantKey(merchantId: string): RuleKey;
|
|
11
|
+
export declare function descriptorKey(descriptor: string): RuleKey;
|
|
12
|
+
export declare function accountPairKey(a: string, b: string): RuleKey;
|
|
13
|
+
export declare function accountIdKey(id: string): RuleKey;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical signatures used as the `key` half of `rules` rows. Derived
|
|
3
|
+
* from the structural context of a question (merchant id, raw descriptor,
|
|
4
|
+
* account pair) — never from prompt prose, because prose embeds volatile
|
|
5
|
+
* data like dates and amounts that would prevent the rule from matching the
|
|
6
|
+
* next time the same pattern appears.
|
|
7
|
+
*/
|
|
8
|
+
const NON_WORD = /[^\p{L}\p{N}]+/gu;
|
|
9
|
+
export function normalizeDescriptor(raw) {
|
|
10
|
+
return raw
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(NON_WORD, " ")
|
|
13
|
+
.replace(/\s+/g, " ")
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
export function merchantKey(merchantId) {
|
|
17
|
+
return `merchant:${merchantId}`;
|
|
18
|
+
}
|
|
19
|
+
export function descriptorKey(descriptor) {
|
|
20
|
+
return `descriptor:${normalizeDescriptor(descriptor)}`;
|
|
21
|
+
}
|
|
22
|
+
export function accountPairKey(a, b) {
|
|
23
|
+
const [lo, hi] = [a, b].sort();
|
|
24
|
+
return `account-pair:${lo}|${hi}`;
|
|
25
|
+
}
|
|
26
|
+
export function accountIdKey(id) {
|
|
27
|
+
return `account:${id}`;
|
|
28
|
+
}
|