plasalid 0.7.2 → 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 +14 -14
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +1 -1
- package/dist/ai/agent.d.ts +5 -5
- package/dist/ai/agent.js +6 -6
- package/dist/ai/personas.d.ts +1 -1
- package/dist/ai/personas.js +14 -14
- 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 +4 -4
- package/dist/ai/tools/clarify.d.ts +2 -0
- package/dist/ai/tools/clarify.js +169 -0
- package/dist/ai/tools/index.js +7 -7
- package/dist/ai/tools/ingest.d.ts +1 -1
- package/dist/ai/tools/ingest.js +8 -8
- package/dist/ai/tools/read.js +1 -1
- package/dist/ai/tools/record.js +5 -5
- package/dist/ai/tools/types.d.ts +2 -2
- package/dist/cli/commands/clarify.d.ts +5 -0
- package/dist/cli/commands/clarify.js +44 -0
- package/dist/cli/commands/rules.js +1 -1
- package/dist/cli/commands/scan.js +9 -9
- package/dist/cli/commands/status.js +1 -1
- package/dist/cli/index.js +6 -6
- package/dist/cli/ink/ScanDashboard.d.ts +1 -1
- package/dist/cli/ink/ScanDashboard.js +2 -2
- package/dist/cli/setup.js +1 -1
- package/dist/cli/ux.js +1 -1
- 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/engine.d.ts +3 -3
- package/dist/scanner/engine.js +8 -8
- package/dist/scanner/hooks.d.ts +3 -3
- package/dist/scanner/worker.d.ts +1 -1
- package/dist/scanner/worker.js +1 -1
- package/package.json +1 -1
package/dist/ai/tools/ingest.js
CHANGED
|
@@ -414,7 +414,7 @@ export const accountIngestTools = {
|
|
|
414
414
|
const QUESTION_DEFS = [
|
|
415
415
|
{
|
|
416
416
|
name: "note_question",
|
|
417
|
-
description: "Record a clarification question without pausing the run. Use SPARINGLY during scan — best-guess expense categorization is preferred (small misses are cheap to fix; a flood of questions is not). Call note_question only when (a) the row is unparseable (skip the row, no transaction_id), (b) you have a doubt about an account itself (pass account_id), or (c) the amount/sign/date/counter-party is genuinely unclear (post your best-guess transaction first, then call this with the transaction_id). Use kind='uncategorized_expense' only for genuinely opaque expense descriptors that landed in expense:uncategorized. The
|
|
417
|
+
description: "Record a clarification question without pausing the run. Use SPARINGLY during scan — best-guess expense categorization is preferred (small misses are cheap to fix; a flood of questions is not). Call note_question only when (a) the row is unparseable (skip the row, no transaction_id), (b) you have a doubt about an account itself (pass account_id), or (c) the amount/sign/date/counter-party is genuinely unclear (post your best-guess transaction first, then call this with the transaction_id). Use kind='uncategorized_expense' only for genuinely opaque expense descriptors that landed in expense:uncategorized. The clarifier picks these up later with the full picture.",
|
|
418
418
|
input_schema: {
|
|
419
419
|
type: "object",
|
|
420
420
|
properties: {
|
|
@@ -424,11 +424,11 @@ const QUESTION_DEFS = [
|
|
|
424
424
|
},
|
|
425
425
|
kind: {
|
|
426
426
|
type: "string",
|
|
427
|
-
description: "Optional category for the question. Use 'uncategorized_expense' when the posting landed in expense:uncategorized; the
|
|
427
|
+
description: "Optional category for the question. Use 'uncategorized_expense' when the posting landed in expense:uncategorized; the clarifier batches these into one cleanup pass.",
|
|
428
428
|
},
|
|
429
429
|
options: {
|
|
430
430
|
type: "array",
|
|
431
|
-
description: "Optional list of candidate answers the
|
|
431
|
+
description: "Optional list of candidate answers the clarifier can offer the user.",
|
|
432
432
|
items: { type: "string" },
|
|
433
433
|
},
|
|
434
434
|
transaction_id: {
|
|
@@ -474,7 +474,7 @@ export const scanQuestionTools = {
|
|
|
474
474
|
const RESOLVE_DEFS = [
|
|
475
475
|
{
|
|
476
476
|
name: "ask_user",
|
|
477
|
-
description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively. Available during `plasalid
|
|
477
|
+
description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively. Available during `plasalid clarify`. Not exposed during `plasalid scan` — use `note_question` instead. Pass `question_id` to close an existing question in place. Pass `related_question_ids` to apply the user's single answer to a whole group of sibling questions at once.",
|
|
478
478
|
input_schema: {
|
|
479
479
|
type: "object",
|
|
480
480
|
properties: {
|
|
@@ -489,7 +489,7 @@ const RESOLVE_DEFS = [
|
|
|
489
489
|
},
|
|
490
490
|
question_id: {
|
|
491
491
|
type: "string",
|
|
492
|
-
description: "Id of the primary question this
|
|
492
|
+
description: "Id of the primary question this clarifies. The user's answer closes (deletes) that row.",
|
|
493
493
|
},
|
|
494
494
|
related_question_ids: {
|
|
495
495
|
type: "array",
|
|
@@ -534,7 +534,7 @@ const RESOLVE_LABELS = {
|
|
|
534
534
|
ask_user: "Asking for clarification",
|
|
535
535
|
close_question: "Closing question",
|
|
536
536
|
};
|
|
537
|
-
async function
|
|
537
|
+
async function clarifyIngestExecute(db, name, input, ctx) {
|
|
538
538
|
if (name === "close_question")
|
|
539
539
|
return closeQuestionTool(db, input, ctx);
|
|
540
540
|
if (name !== "ask_user")
|
|
@@ -590,8 +590,8 @@ async function closeQuestionTool(db, input, ctx) {
|
|
|
590
590
|
}
|
|
591
591
|
return `Closed ${count} question${count === 1 ? "" : "s"}.`;
|
|
592
592
|
}
|
|
593
|
-
export const
|
|
593
|
+
export const clarifyIngestTools = {
|
|
594
594
|
DEFS: RESOLVE_DEFS,
|
|
595
595
|
LABELS: RESOLVE_LABELS,
|
|
596
|
-
execute:
|
|
596
|
+
execute: clarifyIngestExecute,
|
|
597
597
|
};
|
package/dist/ai/tools/read.js
CHANGED
|
@@ -63,7 +63,7 @@ const DEFS = [
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
name: "list_questions",
|
|
66
|
-
description: "List clarification questions recorded by the scanner that have not been resolved yet. Each row carries the prompt, optional candidate answers, and the file/transaction/account it was attached to. The
|
|
66
|
+
description: "List clarification questions recorded by the scanner that have not been resolved yet. Each row carries the prompt, optional candidate answers, and the file/transaction/account it was attached to. The clarifier uses this to drive the step-by-step clarification loop.",
|
|
67
67
|
input_schema: {
|
|
68
68
|
type: "object",
|
|
69
69
|
properties: {
|
package/dist/ai/tools/record.js
CHANGED
|
@@ -77,8 +77,8 @@ const DEFS = [
|
|
|
77
77
|
},
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
|
-
name: "
|
|
81
|
-
description: "Ask the user
|
|
80
|
+
name: "confirm",
|
|
81
|
+
description: "Ask the user to confirm or pick an option and return their answer as a string. Use when the utterance is ambiguous (multiple matching accounts, missing amount, unclear date, can't tell expense vs transfer, plan confirmation before a multi-step action). Unlike clarify's ask_user, this does NOT write to the questions table — record-time questions are transient.",
|
|
82
82
|
input_schema: {
|
|
83
83
|
type: "object",
|
|
84
84
|
properties: {
|
|
@@ -111,7 +111,7 @@ const LABELS = {
|
|
|
111
111
|
adjust_account_balance: "Adjusting balance",
|
|
112
112
|
rename_account: "Renaming account",
|
|
113
113
|
delete_account: "Deleting account",
|
|
114
|
-
|
|
114
|
+
confirm: "Asking for confirmation",
|
|
115
115
|
};
|
|
116
116
|
async function execute(db, name, input, ctx) {
|
|
117
117
|
switch (name) {
|
|
@@ -141,9 +141,9 @@ async function execute(db, name, input, ctx) {
|
|
|
141
141
|
return `Could not delete: ${err.message}`;
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
|
-
case "
|
|
144
|
+
case "confirm": {
|
|
145
145
|
if (!ctx)
|
|
146
|
-
return "
|
|
146
|
+
return "confirm is only available inside an agent session.";
|
|
147
147
|
if (!ctx.interactive || !ctx.promptUser) {
|
|
148
148
|
return `Awaiting user input — cannot proceed in non-interactive mode. Question was: ${sanitizeForPrompt(input.prompt)}`;
|
|
149
149
|
}
|
package/dist/ai/tools/types.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type Database from "libsql";
|
|
|
2
2
|
import type { ToolDefinition } from "../provider.js";
|
|
3
3
|
import type { ScanProgress } from "../../scanner/progress.js";
|
|
4
4
|
import type { ClosedQuestion } from "../../db/queries/questions.js";
|
|
5
|
-
export type ToolProfile = "scan" | "chat" | "record" | "
|
|
5
|
+
export type ToolProfile = "scan" | "chat" | "record" | "clarify";
|
|
6
6
|
/**
|
|
7
7
|
* Structured highlights an interactive agent can pass to ask_user. The prompter
|
|
8
8
|
* renders them as a single colored header line above the question (each
|
|
@@ -30,7 +30,7 @@ export interface AgentExecutionContext {
|
|
|
30
30
|
progress?: ScanProgress;
|
|
31
31
|
/** Scan-only: the chunk this agent invocation is processing. */
|
|
32
32
|
chunkId?: string;
|
|
33
|
-
/**
|
|
33
|
+
/** Clarify-only: notified for each closed question so the caller can synthesize memory rules. */
|
|
34
34
|
onQuestionClosed?: (closed: ClosedQuestion) => void;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getDb } from "../../db/connection.js";
|
|
3
|
+
import { runClarify } from "../../scanner/clarifier.js";
|
|
4
|
+
import { makePromptUser, makeAgentOnProgress, statusSpinner } from "../ux.js";
|
|
5
|
+
/**
|
|
6
|
+
* Zero-arg clarifier. Hands every question to the clarifier (deterministic
|
|
7
|
+
* passes first, then the LLM agent) and prints a colored summary on completion.
|
|
8
|
+
*/
|
|
9
|
+
export async function runClarifyCommand() {
|
|
10
|
+
const db = getDb();
|
|
11
|
+
const spinner = statusSpinner("Clarifying...");
|
|
12
|
+
const promptUser = makePromptUser(spinner);
|
|
13
|
+
const onProgress = makeAgentOnProgress(spinner);
|
|
14
|
+
try {
|
|
15
|
+
const summary = await runClarify({
|
|
16
|
+
db,
|
|
17
|
+
interactive: !!process.stdout.isTTY,
|
|
18
|
+
promptUser,
|
|
19
|
+
onProgress,
|
|
20
|
+
});
|
|
21
|
+
spinner.succeed("Clarify done.");
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log(formatSummary(summary));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
spinner.fail(err instanceof Error ? err.message : "Clarify failed.");
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function formatSummary(summary) {
|
|
31
|
+
if (summary.total === 0) {
|
|
32
|
+
return chalk.dim("No questions.");
|
|
33
|
+
}
|
|
34
|
+
const tally = Object.entries(summary.tally)
|
|
35
|
+
.map(([k, v]) => `${k}×${v}`)
|
|
36
|
+
.join(", ");
|
|
37
|
+
const lines = [
|
|
38
|
+
chalk.bold(`Clarified ${summary.clarified}/${summary.total} questions${tally ? ` (${tally})` : ""}.`),
|
|
39
|
+
];
|
|
40
|
+
if (summary.remaining > 0) {
|
|
41
|
+
lines.push(chalk.yellow(`${summary.remaining} question(s) remain.`));
|
|
42
|
+
}
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
@@ -29,7 +29,7 @@ export function renderRules(db) {
|
|
|
29
29
|
const rules = collectRules(db);
|
|
30
30
|
if (rules.length === 0) {
|
|
31
31
|
return ("No rules yet.\n\n" +
|
|
32
|
-
chalk.dim("Rules accumulate as you
|
|
32
|
+
chalk.dim("Rules accumulate as you clarify questions. Run `plasalid clarify` after a scan."));
|
|
33
33
|
}
|
|
34
34
|
const width = Math.max(...rules.map((r) => r.displayId.length));
|
|
35
35
|
const lines = [chalk.bold(`Rules (${rules.length}):`)];
|
|
@@ -99,10 +99,10 @@ async function buildTtyHooks() {
|
|
|
99
99
|
unsubscribeProgress?.();
|
|
100
100
|
unsubscribeProgress = null;
|
|
101
101
|
},
|
|
102
|
-
|
|
103
|
-
controller.publish({ type: "phase-set", phase: "
|
|
102
|
+
beforeClarify: () => {
|
|
103
|
+
controller.publish({ type: "phase-set", phase: "clarify" });
|
|
104
104
|
},
|
|
105
|
-
|
|
105
|
+
afterClarify: () => {
|
|
106
106
|
controller.publish({ type: "phase-set", phase: "done" });
|
|
107
107
|
inkInstance?.unmount();
|
|
108
108
|
inkInstance = null;
|
|
@@ -191,8 +191,8 @@ function buildPlainHooks() {
|
|
|
191
191
|
unsubscribeProgress?.();
|
|
192
192
|
unsubscribeProgress = null;
|
|
193
193
|
},
|
|
194
|
-
|
|
195
|
-
console.log("
|
|
194
|
+
beforeClarify: () => {
|
|
195
|
+
console.log("Clarifying...");
|
|
196
196
|
},
|
|
197
197
|
};
|
|
198
198
|
}
|
|
@@ -200,11 +200,11 @@ function renderSummary(state) {
|
|
|
200
200
|
console.log("");
|
|
201
201
|
const txCount = countTransactions(state);
|
|
202
202
|
console.log(chalk.bold(`Scanned ${state.decrypted.length} file(s) → ${txCount} transactions.`));
|
|
203
|
-
const r = state.
|
|
203
|
+
const r = state.clarifySummary;
|
|
204
204
|
if (r && r.total > 0) {
|
|
205
|
-
console.log(`
|
|
205
|
+
console.log(`Clarified ${r.clarified}/${r.total} questions.`);
|
|
206
206
|
if (r.remaining > 0) {
|
|
207
|
-
console.log(chalk.yellow(`${r.remaining} question(s) remain — run ${chalk.cyan("plasalid
|
|
207
|
+
console.log(chalk.yellow(`${r.remaining} question(s) remain — run ${chalk.cyan("plasalid clarify")} to finish them.`));
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
if (state.errors.length > 0) {
|
|
@@ -215,7 +215,7 @@ function renderSummary(state) {
|
|
|
215
215
|
}
|
|
216
216
|
if (txCount > 0) {
|
|
217
217
|
console.log("");
|
|
218
|
-
console.log(
|
|
218
|
+
console.log(`Next: run ${chalk.cyan("plasalid")} to chat with your ledger about what just landed.`);
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
/**
|
package/dist/cli/index.js
CHANGED
|
@@ -135,12 +135,12 @@ program
|
|
|
135
135
|
forgetRule(regex);
|
|
136
136
|
});
|
|
137
137
|
program
|
|
138
|
-
.command("
|
|
139
|
-
.description("
|
|
138
|
+
.command("clarify")
|
|
139
|
+
.description("Clarify every question across the ledger")
|
|
140
140
|
.action(async () => {
|
|
141
141
|
ensureConfigured();
|
|
142
|
-
const {
|
|
143
|
-
await
|
|
142
|
+
const { runClarifyCommand } = await import("./commands/clarify.js");
|
|
143
|
+
await runClarifyCommand();
|
|
144
144
|
});
|
|
145
145
|
program.configureHelp({
|
|
146
146
|
formatHelp: () => helpScreen([
|
|
@@ -175,8 +175,8 @@ program.configureHelp({
|
|
|
175
175
|
desc: "Delete learned rules whose ids match <regex> (anchored)",
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
|
-
name: "
|
|
179
|
-
desc: "
|
|
178
|
+
name: "clarify",
|
|
179
|
+
desc: "Clarify every question across the ledger",
|
|
180
180
|
},
|
|
181
181
|
]),
|
|
182
182
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Events the CLI publishes into the dashboard. The CLI subscribes to the
|
|
3
3
|
* scanner's ScanProgress sink and routes per-chunk ticks here via chunkLookup.
|
|
4
4
|
*/
|
|
5
|
-
export type CurrentPhase = "parse" | "
|
|
5
|
+
export type CurrentPhase = "parse" | "clarify" | "done";
|
|
6
6
|
export type DashboardEvent = {
|
|
7
7
|
type: "chunk-start";
|
|
8
8
|
fileId: string;
|
|
@@ -66,7 +66,7 @@ const PHASE_RENDER = {
|
|
|
66
66
|
running: (label) => _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", label] }),
|
|
67
67
|
done: (label) => _jsxs(Text, { color: "green", children: ["\u2713 ", label] }),
|
|
68
68
|
};
|
|
69
|
-
const PHASE_ORDER = ["parse", "
|
|
69
|
+
const PHASE_ORDER = ["parse", "clarify", "done"];
|
|
70
70
|
function phaseStateOf(label, current) {
|
|
71
71
|
const li = PHASE_ORDER.indexOf(label);
|
|
72
72
|
const ci = PHASE_ORDER.indexOf(current);
|
|
@@ -77,7 +77,7 @@ function phaseStateOf(label, current) {
|
|
|
77
77
|
return "pending";
|
|
78
78
|
}
|
|
79
79
|
function Header({ phase }) {
|
|
80
|
-
return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Scanner" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "green", children: "\u2713 decrypt" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsx(Text, { color: "green", children: "\u2713 chunk" }), _jsx(Text, { dimColor: true, children: " -> " }), PHASE_RENDER[phaseStateOf("parse", phase)]("parse"), _jsx(Text, { dimColor: true, children: " -> " }), PHASE_RENDER[phaseStateOf("
|
|
80
|
+
return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Scanner" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "green", children: "\u2713 decrypt" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsx(Text, { color: "green", children: "\u2713 chunk" }), _jsx(Text, { dimColor: true, children: " -> " }), PHASE_RENDER[phaseStateOf("parse", phase)]("parse"), _jsx(Text, { dimColor: true, children: " -> " }), PHASE_RENDER[phaseStateOf("clarify", phase)]("clarify")] }));
|
|
81
81
|
}
|
|
82
82
|
function ColumnHeader() {
|
|
83
83
|
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, children: "status" }) }), _jsx(Box, { width: COL.files, children: _jsx(Text, { dimColor: true, children: "files" }) }), _jsx(Box, { width: COL.transactions, children: _jsx(Text, { dimColor: true, children: "transactions" }) }), _jsx(Box, { width: COL.questions, children: _jsx(Text, { dimColor: true, children: "questions" }) })] }));
|
package/dist/cli/setup.js
CHANGED
|
@@ -36,7 +36,7 @@ function printSummary(dataDir) {
|
|
|
36
36
|
console.log("Next steps:");
|
|
37
37
|
console.log(` 1. Run ${chalk.cyan("plasalid data")} to drop your bank / credit-card statement PDFs in.`);
|
|
38
38
|
console.log(` 2. Run ${chalk.cyan("plasalid scan")} to parse them.`);
|
|
39
|
-
console.log(` 3. Run ${chalk.cyan("plasalid
|
|
39
|
+
console.log(` 3. Run ${chalk.cyan("plasalid clarify")} to work through anything the scanner flagged.`);
|
|
40
40
|
console.log(` 4. Run ${chalk.cyan("plasalid")} to chat with your money.`);
|
|
41
41
|
console.log("");
|
|
42
42
|
console.log(chalk.dim(` Optional: ${chalk.cyan(`plasalid record "..."`)}${chalk.dim(" to record manual/undocumented transaction, balance, or account at any time.")}`));
|
package/dist/cli/ux.js
CHANGED
|
@@ -139,7 +139,7 @@ export function makeAgentOnProgress(spinner, subject) {
|
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
|
-
* Render the structured facts the
|
|
142
|
+
* Render the structured facts the clarify agent attaches to ask_user as a
|
|
143
143
|
* single colored line above the inquirer prompt. Each category has a fixed
|
|
144
144
|
* chalk color so the user's eye picks out the type without reading prose.
|
|
145
145
|
* Returns null when there's nothing to render (so the caller can skip the
|
|
@@ -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 ClarifierContext {
|
|
4
|
+
readonly db: Database.Database;
|
|
5
|
+
readonly tally: Record<string, number>;
|
|
6
|
+
}
|
|
7
|
+
export interface ClarifierPass {
|
|
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: ClarifierContext): Promise<string | null>;
|
|
12
|
+
}
|
|
13
|
+
export interface ClarifySummary {
|
|
14
|
+
readonly total: number;
|
|
15
|
+
readonly clarified: number;
|
|
16
|
+
readonly remaining: number;
|
|
17
|
+
readonly tally: Readonly<Record<string, number>>;
|
|
18
|
+
}
|
|
19
|
+
export interface RunClarifyOpts {
|
|
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 CLARIFIER_PASSES: readonly ClarifierPass[];
|
|
33
|
+
/**
|
|
34
|
+
* Single entry point shared by the in-scan resolve phase and the standalone
|
|
35
|
+
* `plasalid clarify` command. Runs deterministic passes first, then (when
|
|
36
|
+
* interactive) hands the leftovers to the LLM clarifier agent. Closed
|
|
37
|
+
* questions get compacted into scanning_hint memories.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runClarify(opts: RunClarifyOpts): Promise<ClarifySummary>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { closeQuestion, listQuestions, countQuestions, } from "../db/queries/questions.js";
|
|
2
|
+
import { updatePosting } from "../db/queries/transactions.js";
|
|
3
|
+
import { runClarifyAgent } from "../ai/agent.js";
|
|
4
|
+
import { synthesizeMemoryRules } from "./clarifier-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 CLARIFIER_PASSES = [
|
|
65
|
+
memoryRulePass,
|
|
66
|
+
merchantDefaultPass,
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Single entry point shared by the in-scan resolve phase and the standalone
|
|
70
|
+
* `plasalid clarify` command. Runs deterministic passes first, then (when
|
|
71
|
+
* interactive) hands the leftovers to the LLM clarifier agent. Closed
|
|
72
|
+
* questions get compacted into scanning_hint memories.
|
|
73
|
+
*/
|
|
74
|
+
export async function runClarify(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, clarified: 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, clarified: total - remaining, remaining, tally };
|
|
103
|
+
}
|
|
104
|
+
function matchingPasses(u) {
|
|
105
|
+
if (!u.kind)
|
|
106
|
+
return [];
|
|
107
|
+
return CLARIFIER_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(`[clarifier 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 clarifier. 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 runClarifyAgent({
|
|
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_clarification"] = (tally["agent_clarification"] ?? 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
|
+
}
|
package/dist/scanner/engine.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type Database from "libsql";
|
|
|
2
2
|
import type { ScannedFile } from "./walker.js";
|
|
3
3
|
import type { ScanHooks } from "./hooks.js";
|
|
4
4
|
import type { ScanProgress } from "./progress.js";
|
|
5
|
-
import type {
|
|
5
|
+
import type { ClarifySummary } from "./clarifier.js";
|
|
6
6
|
export interface Chunk {
|
|
7
7
|
readonly chunkId: string;
|
|
8
8
|
readonly fileId: string;
|
|
@@ -37,7 +37,7 @@ export interface PhaseError {
|
|
|
37
37
|
readonly target?: string;
|
|
38
38
|
readonly error: unknown;
|
|
39
39
|
}
|
|
40
|
-
export type PhaseName = "decrypt" | "chunk" | "parse" | "
|
|
40
|
+
export type PhaseName = "decrypt" | "chunk" | "parse" | "clarify";
|
|
41
41
|
export interface RunScanOptions {
|
|
42
42
|
regex?: string;
|
|
43
43
|
force?: boolean;
|
|
@@ -70,7 +70,7 @@ export interface ScanState {
|
|
|
70
70
|
skipped: SkippedFile[];
|
|
71
71
|
failed: FailedFile[];
|
|
72
72
|
chunks: Chunk[];
|
|
73
|
-
|
|
73
|
+
clarifySummary: ClarifySummary | null;
|
|
74
74
|
errors: PhaseError[];
|
|
75
75
|
}
|
|
76
76
|
export type Phase = (db: Database.Database, state: ScanState, hooks: ScanHooks) => Promise<void>;
|