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.
Files changed (39) hide show
  1. package/README.md +14 -14
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +1 -1
  4. package/dist/ai/agent.d.ts +5 -5
  5. package/dist/ai/agent.js +6 -6
  6. package/dist/ai/personas.d.ts +1 -1
  7. package/dist/ai/personas.js +14 -14
  8. package/dist/ai/prompt-sections.d.ts +4 -4
  9. package/dist/ai/prompt-sections.js +1 -1
  10. package/dist/ai/system-prompt.d.ts +2 -2
  11. package/dist/ai/system-prompt.js +4 -4
  12. package/dist/ai/tools/clarify.d.ts +2 -0
  13. package/dist/ai/tools/clarify.js +169 -0
  14. package/dist/ai/tools/index.js +7 -7
  15. package/dist/ai/tools/ingest.d.ts +1 -1
  16. package/dist/ai/tools/ingest.js +8 -8
  17. package/dist/ai/tools/read.js +1 -1
  18. package/dist/ai/tools/record.js +5 -5
  19. package/dist/ai/tools/types.d.ts +2 -2
  20. package/dist/cli/commands/clarify.d.ts +5 -0
  21. package/dist/cli/commands/clarify.js +44 -0
  22. package/dist/cli/commands/rules.js +1 -1
  23. package/dist/cli/commands/scan.js +9 -9
  24. package/dist/cli/commands/status.js +1 -1
  25. package/dist/cli/index.js +6 -6
  26. package/dist/cli/ink/ScanDashboard.d.ts +1 -1
  27. package/dist/cli/ink/ScanDashboard.js +2 -2
  28. package/dist/cli/setup.js +1 -1
  29. package/dist/cli/ux.js +1 -1
  30. package/dist/scanner/clarifier-memory.d.ts +8 -0
  31. package/dist/scanner/clarifier-memory.js +24 -0
  32. package/dist/scanner/clarifier.d.ts +39 -0
  33. package/dist/scanner/clarifier.js +196 -0
  34. package/dist/scanner/engine.d.ts +3 -3
  35. package/dist/scanner/engine.js +8 -8
  36. package/dist/scanner/hooks.d.ts +3 -3
  37. package/dist/scanner/worker.d.ts +1 -1
  38. package/dist/scanner/worker.js +1 -1
  39. package/package.json +1 -1
@@ -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 resolver picks these up later with the full picture.",
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 resolver batches these into one cleanup pass.",
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 resolver can offer the user.",
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 resolve`. 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.",
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 resolves. The user's answer closes (deletes) that row.",
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 resolveIngestExecute(db, name, input, ctx) {
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 resolveIngestTools = {
593
+ export const clarifyIngestTools = {
594
594
  DEFS: RESOLVE_DEFS,
595
595
  LABELS: RESOLVE_LABELS,
596
- execute: resolveIngestExecute,
596
+ execute: clarifyIngestExecute,
597
597
  };
@@ -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 resolver uses this to drive the step-by-step clarification loop.",
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: {
@@ -77,8 +77,8 @@ const DEFS = [
77
77
  },
78
78
  },
79
79
  {
80
- name: "clarify",
81
- description: "Ask the user a clarifying question 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 resolve's ask_user, this does NOT write to the questions table — record-time questions are transient.",
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
- clarify: "Asking for clarification",
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 "clarify": {
144
+ case "confirm": {
145
145
  if (!ctx)
146
- return "clarify is only available inside an agent session.";
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
  }
@@ -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" | "resolve";
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
- /** Resolve-only: notified for each closed question so the caller can synthesize memory rules. */
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,5 @@
1
+ /**
2
+ * Zero-arg clarifier. Hands every question to the clarifier (deterministic
3
+ * passes first, then the LLM agent) and prints a colored summary on completion.
4
+ */
5
+ export declare function runClarifyCommand(): Promise<void>;
@@ -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 resolve questions. Run `plasalid resolve` after a scan."));
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
- beforeResolve: () => {
103
- controller.publish({ type: "phase-set", phase: "resolve" });
102
+ beforeClarify: () => {
103
+ controller.publish({ type: "phase-set", phase: "clarify" });
104
104
  },
105
- afterResolve: () => {
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
- beforeResolve: () => {
195
- console.log("Resolving...");
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.resolveSummary;
203
+ const r = state.clarifySummary;
204
204
  if (r && r.total > 0) {
205
- console.log(`Resolved ${r.resolved}/${r.total} questions.`);
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 resolve")} to finish them.`));
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(chalk.dim(`Next: run ${chalk.cyan("plasalid")} to chat with your ledger about what just landed.`));
218
+ console.log(`Next: run ${chalk.cyan("plasalid")} to chat with your ledger about what just landed.`);
219
219
  }
220
220
  }
221
221
  /**
@@ -67,7 +67,7 @@ function systemRows(db) {
67
67
  rows.push({
68
68
  label: "Questions",
69
69
  value: chalk.yellow(formatInteger(questions)),
70
- suffix: chalk.dim("run `plasalid resolve`"),
70
+ suffix: chalk.dim("run `plasalid clarify`"),
71
71
  });
72
72
  }
73
73
  return rows;
package/dist/cli/index.js CHANGED
@@ -135,12 +135,12 @@ program
135
135
  forgetRule(regex);
136
136
  });
137
137
  program
138
- .command("resolve")
139
- .description("Resolve every question across the ledger")
138
+ .command("clarify")
139
+ .description("Clarify every question across the ledger")
140
140
  .action(async () => {
141
141
  ensureConfigured();
142
- const { runResolveCommand } = await import("./commands/resolve.js");
143
- await runResolveCommand();
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: "resolve",
179
- desc: "Resolve every question across the ledger",
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" | "resolve" | "done";
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", "resolve", "done"];
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("resolve", phase)]("resolve")] }));
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 resolve")} to work through anything the scanner flagged.`);
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 resolve agent attaches to ask_user as a
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
+ }
@@ -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 { ResolveSummary } from "./resolver.js";
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" | "resolve";
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
- resolveSummary: ResolveSummary | null;
73
+ clarifySummary: ClarifySummary | null;
74
74
  errors: PhaseError[];
75
75
  }
76
76
  export type Phase = (db: Database.Database, state: ScanState, hooks: ScanHooks) => Promise<void>;