@wirecat/cli-core 0.588.0 → 0.590.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/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.590.0 — 06.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **Project status calls a word-found money request `asked`, not `open`.** The `amounts` signal (version 3) gives
15
+ `state: "asked"` where it gave `"open"`, and `attention` says `money asked`. A word match cannot tell a real request
16
+ from a price the owner quoted, and "open" read as a debt. `paid-mentioned` and `mentioned` are unchanged.
17
+ - **Agents write organizations at once.** `proposeOrganization` and `proposeOrganizationRename` are gone: an agent's
18
+ `organizations add` and `organizations rename` call `store.knowledge.addOrganization` and `updateOrganization` as the
19
+ owner's do, as deals do since 0.588.0. `organization_add` and `organization_rename` rows already stored stay listed
20
+ and decidable through the `organization` review kind, and `ORGANIZATION_ADD_KIND` and `ORGANIZATION_RENAME_KIND`
21
+ stay exported.
22
+
23
+ ## 0.589.0 — 06.11.2026
24
+
25
+ ### Added
26
+
27
+ - **`crm` is a provider kind, and `search all` reads deals and leads.** A CRM CLI registers its provider with
28
+ `kind: "crm"` and `opens: CRM_OPENS` (`deals show`, `leads show`, `pipelines show`). `searchAll` has a resource
29
+ `crm` that finds deals and leads, the owner's own and every CRM account's copies, by the words of their title in
30
+ any letter case; its hits are `deal` and `lead`, their title and excerpt marked untrusted. A query with a field,
31
+ a wildcard or NOT skips `crm` with its reason. `--source` and a profile lock narrow it to the accounts named.
32
+ `crm` joins the default `RESOURCES_SEARCHED`; tg, max, wa and the calendar CLIs pass their own lists and are unchanged.
33
+
34
+ ### Changed — may break callers
35
+
36
+ - **Project status money comes only from the books.** `ProjectStatus.money.open` sums what confirmed open
37
+ invoices still owe and never an amount found by words in messages, so a project with no confirmed invoices answers
38
+ `money: []`. When the project or a child has a confirmed invoice or payment, the `amounts` signal (version 2) lists
39
+ nothing: its word match also found the owner's own quotes, product prices and deposits and called them open, about
40
+ 50 of them on one real project whose books say nothing is owed. A project with no books still gets its `amounts`
41
+ items, marked `basis: pattern`. `StatusContext` has a new required `books` flag.
42
+
10
43
  ## 0.588.0 — 06.11.2026
11
44
 
12
45
  ### Changed — may break callers
@@ -280,6 +280,9 @@ const ALL_HIT_UNTRUSTED = {
280
280
  note: undefined,
281
281
  event: ["title", "excerpt"],
282
282
  meeting: ["title", "excerpt", "sender_name"],
283
+ // A CRM's copy is typed by the CRM's users; the owner's own deal is his, but one rule keeps a hit's marking simple.
284
+ deal: ["title", "excerpt"],
285
+ lead: ["title", "excerpt"],
283
286
  };
284
287
  export const allHitItem = (item) => item.kind === "meeting"
285
288
  ? {
@@ -80,7 +80,7 @@ const partial = (found, chat) => {
80
80
  const readFrom = (found, chat) => {
81
81
  const reply = found.items.find((one) => one.signal === "reply-next" && one.chat === chat)?.data;
82
82
  const money = found.items
83
- .filter((one) => one.signal === "amounts" && one.chat === chat && one.data.state === "open")
83
+ .filter((one) => one.signal === "amounts" && one.chat === chat && one.data.state === "asked")
84
84
  .map((one) => one.data.since)
85
85
  .sort()[0];
86
86
  return day([money, reply?.waitingSince, found.window.since].find((one) => typeof one === "string"));
@@ -1,7 +1,7 @@
1
1
  import type { Provider } from "./models.js";
2
2
  import { type RefKindName } from "./references.js";
3
3
  /** What a search reads; the same names `--only`, `searched` and `skipped` use. */
4
- export declare const KINDS: readonly ["messages", "mail", "notes", "documents", "meetings", "events"];
4
+ export declare const KINDS: readonly ["messages", "mail", "notes", "documents", "meetings", "events", "crm"];
5
5
  export type Kind = (typeof KINDS)[number];
6
6
  export interface ProviderEntry {
7
7
  name: Provider;
@@ -12,6 +12,12 @@ export interface ProviderEntry {
12
12
  /** The read command, after `<command> <profile>`, that opens a ref of each kind: `{ chat: "messages list" }`. */
13
13
  opens?: Partial<Record<RefKindName, string>>;
14
14
  }
15
+ /** What a CRM CLI's entry gives as `opens`: the read commands `addCrmCommands` mounts. */
16
+ export declare const CRM_OPENS: {
17
+ readonly deal: "deals show";
18
+ readonly lead: "leads show";
19
+ readonly pipeline: "pipelines show";
20
+ };
15
21
  /** The seed: what cli-core knows before any CLI has written its entry into the store. */
16
22
  export declare const PROVIDERS: readonly ProviderEntry[];
17
23
  /** How a text names a provider to a person: `MAX`, not `max`. */
@@ -1,8 +1,10 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { REF_KINDS } from "./references.js";
3
3
  /** What a search reads; the same names `--only`, `searched` and `skipped` use. */
4
- export const KINDS = ["messages", "mail", "notes", "documents", "meetings", "events"];
4
+ export const KINDS = ["messages", "mail", "notes", "documents", "meetings", "events", "crm"];
5
5
  const MESSENGER_OPENS = { chat: "messages list", message: "messages show", contact: "contacts show" };
6
+ /** What a CRM CLI's entry gives as `opens`: the read commands `addCrmCommands` mounts. */
7
+ export const CRM_OPENS = { deal: "deals show", lead: "leads show", pipeline: "pipelines show" };
6
8
  /** The seed: what cli-core knows before any CLI has written its entry into the store. */
7
9
  export const PROVIDERS = [
8
10
  { name: "telegram", kind: "messages", aliases: ["tg"], command: "tg", opens: MESSENGER_OPENS },
@@ -1,7 +1,7 @@
1
1
  import * as v from "valibot";
2
- /** One result of `search all`: a message, a mail, a note, a calendar event or a meeting passage, best first. */
2
+ /** One result of `search all`: a message, a mail, a note, a calendar event, a meeting passage, a deal or a lead. */
3
3
  export declare const hit: v.StrictObjectSchema<{
4
- readonly kind: v.PicklistSchema<["message", "mail", "note", "document", "event", "meeting"], undefined>;
4
+ readonly kind: v.PicklistSchema<["message", "mail", "note", "document", "event", "meeting", "deal", "lead"], undefined>;
5
5
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
6
6
  readonly provider: v.StringSchema<undefined>;
7
7
  readonly url: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
@@ -1,9 +1,9 @@
1
1
  import * as v from "valibot";
2
2
  import { hitFile } from "./attachment.js";
3
3
  import { count, reference, text, time, untrusted } from "./fields.js";
4
- /** One result of `search all`: a message, a mail, a note, a calendar event or a meeting passage, best first. */
4
+ /** One result of `search all`: a message, a mail, a note, a calendar event, a meeting passage, a deal or a lead. */
5
5
  export const hit = v.strictObject({
6
- kind: v.picklist(["message", "mail", "note", "document", "event", "meeting"]),
6
+ kind: v.picklist(["message", "mail", "note", "document", "event", "meeting", "deal", "lead"]),
7
7
  ref: reference,
8
8
  provider: text,
9
9
  url: v.optional(text),
@@ -4065,7 +4065,7 @@ export declare const ENTITIES: {
4065
4065
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
4066
4066
  }, undefined>;
4067
4067
  readonly hit: v.StrictObjectSchema<{
4068
- readonly kind: v.PicklistSchema<["message", "mail", "note", "document", "event", "meeting"], undefined>;
4068
+ readonly kind: v.PicklistSchema<["message", "mail", "note", "document", "event", "meeting", "deal", "lead"], undefined>;
4069
4069
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4070
4070
  readonly provider: v.StringSchema<undefined>;
4071
4071
  readonly url: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export type * from "./domain/models.js";
12
12
  export { CURRENCY_SCALES, currencyOf, type Money, minorUnits, moneyLabel, moneyOf, } from "./domain/money.js";
13
13
  export { ORDER_PURPOSES, ORDER_STATUSES, type OrderPurpose, type OrderStatus, } from "./domain/orders.js";
14
14
  export { countedPoll } from "./domain/polls.js";
15
- export { FOLDER_PROVIDERS, isFolderProvider, isKind, KINDS, type Kind, kindIn, kindOf, PROVIDERS, type ProviderEntry, providerNamed, providers, UNKNOWN_PROVIDER, useProviders, } from "./domain/providers.js";
15
+ export { CRM_OPENS, FOLDER_PROVIDERS, isFolderProvider, isKind, KINDS, type Kind, kindIn, kindOf, PROVIDERS, type ProviderEntry, providerNamed, providers, UNKNOWN_PROVIDER, useProviders, } from "./domain/providers.js";
16
16
  export type { RankingGraphEvidence, RankingGraphLink } from "./domain/rankings-graph.js";
17
17
  export type { RankingComponent, RankingInput, RankingMeasure, RankingOptions, RankingTarget, RankingWeights, ScorePreset, } from "./domain/rankings-options.js";
18
18
  export { rankingOptions } from "./domain/rankings-options.js";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export { parseMarkdownSpans } from "./domain/markdown-spans.js";
9
9
  export { CURRENCY_SCALES, currencyOf, minorUnits, moneyLabel, moneyOf, } from "./domain/money.js";
10
10
  export { ORDER_PURPOSES, ORDER_STATUSES, } from "./domain/orders.js";
11
11
  export { countedPoll } from "./domain/polls.js";
12
- export { FOLDER_PROVIDERS, isFolderProvider, isKind, KINDS, kindIn, kindOf, PROVIDERS, providerNamed, providers, UNKNOWN_PROVIDER, useProviders, } from "./domain/providers.js";
12
+ export { CRM_OPENS, FOLDER_PROVIDERS, isFolderProvider, isKind, KINDS, kindIn, kindOf, PROVIDERS, providerNamed, providers, UNKNOWN_PROVIDER, useProviders, } from "./domain/providers.js";
13
13
  export { rankingOptions } from "./domain/rankings-options.js";
14
14
  export { envelopeOfJsonl, RECORD_ENTITY_TYPES, RECORD_OPERATIONS, RECORDS_ENVELOPE_SCHEMA, RECORDS_SCHEMA_VERSION, } from "./domain/records.js";
15
15
  export { canonicalReference, formatReference, looksLikeReference, parseReference, REF_KINDS, } from "./domain/references.js";
@@ -203,6 +203,7 @@ const READS = {
203
203
  documents: "documents — search notes reads them",
204
204
  meetings: "meetings — search all reads them",
205
205
  events: "calendar events — no search reads them yet",
206
+ crm: "CRM deals and leads — search all reads them",
206
207
  };
207
208
  /**
208
209
  * The accounts of `kinds` the entries name, and each entry that names none of them with the reason. A search
@@ -188,7 +188,7 @@ export { type PackageUpgradeOutcome, type PackageUpgradePorts, type PackageUpgra
188
188
  export { type PersonMeetingContext, type PersonMeetingContextItem, type PersonMeetingContextOptions, type PersonMeetingReadStore, personMeetingContext, } from "./person-meeting-context.js";
189
189
  export { privatePeopleService } from "./private-people.js";
190
190
  export { approveAsTask, type ProposalTaskStore, proposalSource } from "./proposal-tasks.js";
191
- export { type Decided, type Decision, emptyProposalRegistry, FACT_SUMMARIES, ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND, type OrganizationProposal, type OrganizationRenameProposal, type ProposalFilter, type ProposalKind, type ProposalRegistry, type ProposalRow, parseProposalIds, proposalRegistry, proposeOrganization, proposeOrganizationRename, } from "./proposals/index.js";
191
+ export { type Decided, type Decision, emptyProposalRegistry, FACT_SUMMARIES, ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND, type OrganizationProposal, type OrganizationRenameProposal, type ProposalFilter, type ProposalKind, type ProposalRegistry, type ProposalRow, parseProposalIds, proposalRegistry, } from "./proposals/index.js";
192
192
  export type { RetentionQuery, RetentionService } from "./retention.js";
193
193
  export { retentionService } from "./retention.js";
194
194
  export { RESOURCES_SEARCHED, type SearchAllFound, type SearchAllItem, type SearchAllMessenger, type SearchAllRequest, type SearchedResource, searchAll, } from "./search-all.js";
@@ -146,7 +146,7 @@ export { upgradePackage, } from "./package-upgrade.js";
146
146
  export { personMeetingContext, } from "./person-meeting-context.js";
147
147
  export { privatePeopleService } from "./private-people.js";
148
148
  export { approveAsTask, proposalSource } from "./proposal-tasks.js";
149
- export { emptyProposalRegistry, FACT_SUMMARIES, ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND, parseProposalIds, proposalRegistry, proposeOrganization, proposeOrganizationRename, } from "./proposals/index.js";
149
+ export { emptyProposalRegistry, FACT_SUMMARIES, ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND, parseProposalIds, proposalRegistry, } from "./proposals/index.js";
150
150
  export { retentionService } from "./retention.js";
151
151
  export { RESOURCES_SEARCHED, searchAll, } from "./search-all.js";
152
152
  export { RESOURCES_SEARCHED_WITH_MEETINGS, searchAllIncludingMeetings, searchAllWithMeetings, } from "./search-all-meetings.js";
@@ -60,7 +60,7 @@ export interface ProjectStatus {
60
60
  chats: StatusChatSummary[];
61
61
  /** Every signal's items, in signal order, then chat order. */
62
62
  items: StatusItem[];
63
- /** Open amounts and what confirmed open invoices still owe, summed per direction and currency. */
63
+ /** What confirmed open invoices still owe, summed per direction and currency; never an amount found in words. */
64
64
  money: {
65
65
  open: MoneyTotal[];
66
66
  };
@@ -53,23 +53,23 @@ const childrenOf = async (store, root, catalog) => {
53
53
  };
54
54
  const inAccounts = (accounts, provider, account) => accounts === undefined || accounts.some((one) => one.provider === provider && one.account === account);
55
55
  const failure = (error) => (error instanceof Error ? error.message : String(error));
56
- /**
57
- * An open ask, or a confirmed open invoice; an ask that a confirmed row cites is counted once, as the row. A proposed
58
- * row promises nothing yet, so the ask it cites still counts.
59
- */
60
- const counted = (items) => {
61
- const confirmed = (item) => item.signal === "payments" && item.data.status === "confirmed";
62
- const recorded = new Set(items.filter(confirmed).flatMap((item) => item.evidence.slice(1).map((one) => one.ref)));
63
- return (item, state) => item.signal === "payments"
64
- ? state === "expected" && confirmed(item)
65
- : item.signal === "amounts" && state === "open" && !item.evidence.some((one) => recorded.has(one.ref));
56
+ const owed = (item) => {
57
+ const data = item.data;
58
+ return item.signal === "payments" && data.state === "expected" && data.status === "confirmed";
59
+ };
60
+ const keepsBooks = async (store, projects) => {
61
+ for (const project of projects) {
62
+ const filter = { project, review: "confirmed", limit: 1 };
63
+ if ((await store.finance.invoices(filter)).length || (await store.finance.payments(filter)).length)
64
+ return true;
65
+ }
66
+ return false;
66
67
  };
67
68
  const totalsOf = (items) => {
68
69
  const totals = new Map();
69
- const open = counted(items);
70
70
  for (const item of items) {
71
71
  const data = item.data;
72
- if (!open(item, data.state) || data.amount === undefined)
72
+ if (!owed(item) || data.amount === undefined)
73
73
  continue;
74
74
  const key = `${data.direction}|${data.currency ?? ""}`;
75
75
  const total = totals.get(key) ?? {
@@ -157,6 +157,7 @@ export const projectStatus = async (store, input) => {
157
157
  chats,
158
158
  project: { ...root, children, cut },
159
159
  store,
160
+ books: await keepsBooks(store, [root.ref, ...children.map((child) => child.ref)]),
160
161
  contextQueries: input.contextQueries ?? [],
161
162
  };
162
163
  const items = [];
@@ -5,8 +5,11 @@ export interface AmountsData {
5
5
  amount: string;
6
6
  currency: string | null;
7
7
  direction: MoneyDirection;
8
- /** `paid-mentioned` is a message saying so, never a payment the owner recorded. */
9
- state: "open" | "paid-mentioned" | "mentioned";
8
+ /**
9
+ * `asked` is a message asking for it, not a debt: a word match cannot tell a request from a price the owner quoted.
10
+ * `paid-mentioned` is a message saying so, never a payment the owner recorded.
11
+ */
12
+ state: "asked" | "paid-mentioned" | "mentioned";
10
13
  /** The first ask, or the first mention when nobody asked. */
11
14
  since: string;
12
15
  lastAsked: string | null;
@@ -16,5 +19,6 @@ export declare const centsText: (cents: number) => string;
16
19
  /**
17
20
  * Every message with an amount and a money word, folded by amount and currency per chat: the first ask is `since`, a
18
21
  * later "paid" message naming the same amount makes it `paid-mentioned`. Instalments are not summed into a total.
22
+ * Nothing when the project keeps books: a word match also finds the owner's quotes and prices, which owe nothing.
19
23
  */
20
24
  export declare const amounts: StatusSignal<AmountsData>;
@@ -47,13 +47,14 @@ export const centsText = (cents) => `${Math.floor(cents / 100)}.${String(cents %
47
47
  /**
48
48
  * Every message with an amount and a money word, folded by amount and currency per chat: the first ask is `since`, a
49
49
  * later "paid" message naming the same amount makes it `paid-mentioned`. Instalments are not summed into a total.
50
+ * Nothing when the project keeps books: a word match also finds the owner's quotes and prices, which owe nothing.
50
51
  */
51
52
  export const amounts = {
52
53
  id: "amounts",
53
- version: 1,
54
+ version: 3,
54
55
  scope: "chat",
55
- read: async (_context, chat) => {
56
- if (!chat)
56
+ read: async (context, chat) => {
57
+ if (!chat || context.books)
57
58
  return [];
58
59
  const groups = new Map();
59
60
  for (const row of rowsOf(chat)) {
@@ -66,7 +67,8 @@ export const amounts = {
66
67
  const lastAsk = asks.at(-1);
67
68
  const first = (asks[0] ?? done[0] ?? rows[0]);
68
69
  const paidAfter = done.some((row) => !lastAsk || Date.parse(row.message.timestamp) >= Date.parse(lastAsk.message.timestamp));
69
- const state = asks.length ? (paidAfter ? "paid-mentioned" : "open") : done.length ? "paid-mentioned" : "mentioned";
70
+ const paid = asks.length ? paidAfter : done.length > 0;
71
+ const state = paid ? "paid-mentioned" : asks.length ? "asked" : "mentioned";
70
72
  const cited = asks.length ? [...asks, ...(paidAfter ? done.slice(-1) : [])] : rows;
71
73
  return {
72
74
  signal: "amounts",
@@ -87,5 +89,5 @@ export const amounts = {
87
89
  };
88
90
  });
89
91
  },
90
- attention: (items) => items.flatMap((item) => item.chat !== null && item.data.state === "open" ? [{ chat: item.chat, weight: 2, why: "money open" }] : []),
92
+ attention: (items) => items.flatMap((item) => item.chat !== null && item.data.state === "asked" ? [{ chat: item.chat, weight: 2, why: "money asked" }] : []),
91
93
  };
@@ -43,6 +43,8 @@ export interface StatusContext {
43
43
  };
44
44
  /** Read only: facts, tasks, chats. */
45
45
  store: MessageStore;
46
+ /** The project or a child has a confirmed invoice or payment: money is then read from those rows, never from words. */
47
+ books: boolean;
46
48
  /** `--context-query` texts: every word of one must appear in one message. */
47
49
  contextQueries: string[];
48
50
  }
@@ -1,5 +1,5 @@
1
1
  export { parseProposalIds } from "./ids.js";
2
2
  export { FACT_SUMMARIES } from "./kinds/findings.js";
3
- export { ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND, type OrganizationProposal, type OrganizationRenameProposal, proposeOrganization, proposeOrganizationRename, } from "./kinds/organization.js";
3
+ export { ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND, type OrganizationProposal, type OrganizationRenameProposal, } from "./kinds/organization.js";
4
4
  export { emptyProposalRegistry, proposalRegistry } from "./registry.js";
5
5
  export type { Decided, Decision, ProposalFilter, ProposalKind, ProposalRegistry, ProposalRow } from "./types.js";
@@ -1,4 +1,4 @@
1
1
  export { parseProposalIds } from "./ids.js";
2
2
  export { FACT_SUMMARIES } from "./kinds/findings.js";
3
- export { ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND, proposeOrganization, proposeOrganizationRename, } from "./kinds/organization.js";
3
+ export { ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND, } from "./kinds/organization.js";
4
4
  export { emptyProposalRegistry, proposalRegistry } from "./registry.js";
@@ -1,4 +1,4 @@
1
- import type { Author, MessageStore, Organization, ProposedAction } from "../../../store/index.js";
1
+ import type { MessageStore, Organization } from "../../../store/index.js";
2
2
  import type { ProposalKind } from "../types.js";
3
3
  export declare const ORGANIZATION_ADD_KIND = "organization_add";
4
4
  export declare const ORGANIZATION_RENAME_KIND = "organization_rename";
@@ -11,8 +11,5 @@ export interface OrganizationRenameProposal {
11
11
  organization: string;
12
12
  name: string;
13
13
  }
14
- /** An agent's new organization waits as a stored proposal; the organization exists only once it is confirmed. */
15
- export declare const proposeOrganization: (store: Pick<MessageStore, "proposedActions">, input: OrganizationProposal, by: Author, reason?: string) => Promise<ProposedAction>;
16
- /** An agent's rename waits as a stored proposal too; the organization keeps its name until it is confirmed. */
17
- export declare const proposeOrganizationRename: (store: Pick<MessageStore, "proposedActions">, input: OrganizationRenameProposal, by: Author, reason?: string) => Promise<ProposedAction>;
14
+ /** Only rows stored before agents wrote organizations at once; they stay decidable in review. */
18
15
  export declare const organizationKind: (store: Pick<MessageStore, "proposedActions" | "knowledge">) => ProposalKind;
@@ -23,20 +23,7 @@ const rowOf = (action) => {
23
23
  createdAt: action.createdAt,
24
24
  };
25
25
  };
26
- /** An agent's new organization waits as a stored proposal; the organization exists only once it is confirmed. */
27
- export const proposeOrganization = (store, input, by, reason) => store.proposedActions.propose({
28
- kind: ORGANIZATION_ADD_KIND,
29
- by,
30
- payload: input,
31
- ...(reason === undefined ? {} : { reason }),
32
- });
33
- /** An agent's rename waits as a stored proposal too; the organization keeps its name until it is confirmed. */
34
- export const proposeOrganizationRename = (store, input, by, reason) => store.proposedActions.propose({
35
- kind: ORGANIZATION_RENAME_KIND,
36
- by,
37
- payload: input,
38
- ...(reason === undefined ? {} : { reason }),
39
- });
26
+ /** Only rows stored before agents wrote organizations at once; they stay decidable in review. */
40
27
  export const organizationKind = (store) => ({
41
28
  kind: "organization",
42
29
  stored: [ORGANIZATION_ADD_KIND, ORGANIZATION_RENAME_KIND],
@@ -3,7 +3,7 @@ import type { AccountKey, MessageStore } from "../store/store.js";
3
3
  import type { SearchFound, SearchQuery } from "./messages.js";
4
4
  import type { MeaningLane } from "./messages-discovery.js";
5
5
  import { type SearchAllFound, type SearchAllItem, type SearchAllMessenger, type SearchAllRequest, type Skipped } from "./search-all.js";
6
- export declare const RESOURCES_SEARCHED_WITH_MEETINGS: readonly ["messages", "mail", "notes", "events", "meetings"];
6
+ export declare const RESOURCES_SEARCHED_WITH_MEETINGS: readonly ["messages", "mail", "notes", "events", "crm", "meetings"];
7
7
  export type ResourceWithMeetings = (typeof RESOURCES_SEARCHED_WITH_MEETINGS)[number];
8
8
  export interface MeetingSearchItem {
9
9
  kind: "meeting";
@@ -274,6 +274,7 @@ const searchAllOfSource = async (store, account, source, request, messenger, sea
274
274
  mail: named("mail"),
275
275
  notes: named("notes"),
276
276
  events: named("events"),
277
+ crm: named("crm"),
277
278
  },
278
279
  ...(meetings[0] ? { meetingAccount: meetings[0] } : {}),
279
280
  }, messenger, searchMessages, meaning);
@@ -7,11 +7,11 @@ import type { SearchFound, SearchQuery } from "./messages.js";
7
7
  import { type MeaningLane, type MeaningSkipped } from "./messages-discovery.js";
8
8
  import type { SearchCoverage } from "./messages-search.js";
9
9
  import type { Backend, ServerOptions, ServerSearched } from "./server-search.js";
10
- export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes", "events"];
10
+ export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes", "events", "crm"];
11
11
  export type SearchedResource = (typeof RESOURCES_SEARCHED)[number];
12
12
  export interface SearchAllItem {
13
- kind: "message" | "mail" | "note" | "document" | "event";
14
- /** `msg:…` for a message or a mail, `note:…` for a note, `event:…` for a calendar event. */
13
+ kind: "message" | "mail" | "note" | "document" | "event" | "deal" | "lead";
14
+ /** `msg:…` for a message or a mail, `note:…` for a note, `event:…` for a calendar event, `deal:…`, `lead:…`. */
15
15
  ref: string;
16
16
  provider: string;
17
17
  /** The account it was found in; a note's folder id, or `internal` for a note written in memo. */
@@ -97,7 +97,7 @@ export interface SearchAllRequest {
97
97
  only?: readonly SearchedResource[];
98
98
  exact?: boolean;
99
99
  timezone?: string;
100
- /** For messages only; mail, notes and events are always the local store's. */
100
+ /** For messages only; mail, notes, events and CRM copies are always the local store's. */
101
101
  backend?: Backend;
102
102
  server?: ServerOptions;
103
103
  env?: NodeJS.ProcessEnv;
@@ -113,7 +113,17 @@ export type SearchAllMessenger = Partial<Pick<MessengerTraits, "savedChatId" | "
113
113
  hintCommand?: string | null;
114
114
  };
115
115
  /**
116
- * `search all`: messages, mail, notes and calendar events from the local store — messages also from the messenger's
116
+ * What a deal or a lead is found by: words or phrases of its title, all of them, or any with OR. Anything else is
117
+ * refused, so `search all` skips deals with the reason instead of matching a field they do not have.
118
+ */
119
+ export declare const titleWords: (text: string) => {
120
+ words: string[];
121
+ anyWord: boolean;
122
+ };
123
+ /** The accounts of every CRM the store holds copies of. */
124
+ export declare const crmAccounts: (store: MessageStore) => Promise<AccountKey[]>;
125
+ /**
126
+ * `search all`: messages, mail, notes, calendar events and CRM deals and leads from the local store — messages also from the messenger's
117
127
  * server, as `search messages` asks it — each list best first, merged by reciprocal rank so no
118
128
  * resource's own scores have to be compared with another's. Events are read only when the store holds a calendar.
119
129
  */
@@ -6,14 +6,14 @@ import { senderIsMe } from "../domain/models.js";
6
6
  import { INTERNAL_NOTES, kindIn } from "../domain/providers.js";
7
7
  import { runEmbedders } from "../embeddings/embed.js";
8
8
  import { DEFAULT_TEXT_MODEL } from "../embeddings/models.js";
9
- import { MESSAGE_PARSING } from "../search/lucene/types.js";
9
+ import { MESSAGE_PARSING, SEARCH_QUERY } from "../search/lucene/types.js";
10
10
  import { planQuestion, questionLookup } from "../search/question-plan.js";
11
11
  import { calendarAccounts, searchEvents } from "./events-search.js";
12
12
  import { excerpt } from "./excerpt.js";
13
13
  import { MAX_SEARCH_OFFSET, searchStore } from "./messages-archive-search.js";
14
14
  import { nearEligible } from "./messages-discovery.js";
15
15
  import { notesScopeOf, RRF_K, searchNotes } from "./notes-search.js";
16
- export const RESOURCES_SEARCHED = ["messages", "mail", "notes", "events"];
16
+ export const RESOURCES_SEARCHED = ["messages", "mail", "notes", "events", "crm"];
17
17
  /** A query a resource cannot answer is skipped with its reason, never a failure of the whole search. */
18
18
  const skippable = (error) => error instanceof CliError && (error.code === "validation_error" || error.code === "not_found");
19
19
  /**
@@ -31,7 +31,26 @@ const filtersBeside = (text) => {
31
31
  : undefined;
32
32
  };
33
33
  /**
34
- * `search all`: messages, mail, notes and calendar events from the local store — messages also from the messenger's
34
+ * What a deal or a lead is found by: words or phrases of its title, all of them, or any with OR. Anything else is
35
+ * refused, so `search all` skips deals with the reason instead of matching a field they do not have.
36
+ */
37
+ export const titleWords = (text) => {
38
+ const { root } = parseLucene(text, SEARCH_QUERY);
39
+ const clauses = root.kind === "boolean" ? root.clauses : [{ node: root, occur: "must" }];
40
+ const words = clauses.map(({ node, occur }) => occur !== "must_not" &&
41
+ node.kind === "predicate" &&
42
+ node.field === "text" &&
43
+ (node.operator === "term" || node.operator === "phrase")
44
+ ? node.value
45
+ : (() => {
46
+ throw new CliError("validation_error", "deals and leads are found by the words of their title only");
47
+ })());
48
+ return { words, anyWord: clauses.length > 1 && clauses.every(({ occur }) => occur === "should") };
49
+ };
50
+ /** The accounts of every CRM the store holds copies of. */
51
+ export const crmAccounts = async (store) => (await store.accounts.accounts()).filter(({ provider }) => kindIn(provider) === "crm");
52
+ /**
53
+ * `search all`: messages, mail, notes, calendar events and CRM deals and leads from the local store — messages also from the messenger's
35
54
  * server, as `search messages` asks it — each list best first, merged by reciprocal rank so no
36
55
  * resource's own scores have to be compared with another's. Events are read only when the store holds a calendar.
37
56
  */
@@ -232,6 +251,30 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
232
251
  })));
233
252
  accounts.push(...read.map(({ provider, account }) => ({ resource, provider, account })));
234
253
  }
254
+ else if (resource === "crm") {
255
+ const { words, anyWord } = titleWords(request.text);
256
+ const chosen = request.scope?.crm;
257
+ const found = await store.deals.find({
258
+ words,
259
+ anyWord: anyWord || discover,
260
+ limit: Math.min(window + 1, 500),
261
+ ...(chosen === undefined ? {} : { accounts: chosen }),
262
+ });
263
+ hasMore ||= found.length > window;
264
+ lists.push(found.slice(0, window).map((deal) => ({
265
+ kind: deal.kind,
266
+ ref: deal.ref,
267
+ provider: deal.account?.provider ?? "crm",
268
+ account: deal.account?.account ?? INTERNAL_NOTES,
269
+ title: deal.title,
270
+ timestamp: deal.updatedAt,
271
+ text: deal.summary,
272
+ foundBy: ["title"],
273
+ })));
274
+ const read = chosen ?? [...(await crmAccounts(store)), { provider: "crm", account: INTERNAL_NOTES }];
275
+ for (const { provider, account } of read)
276
+ accounts.push({ resource, provider, account });
277
+ }
235
278
  else
236
279
  lists.push(await messagesOf(resource));
237
280
  searched.push(resource);
@@ -1,4 +1,5 @@
1
1
  import { type DealKind, type DealRole, type DealStatus, type StageOutcome } from "../../domain/deals.js";
2
+ import type { AccountKey } from "../types.js";
2
3
  import { type ProposedByFilter } from "./actors.js";
3
4
  import { type Party } from "./finance.js";
4
5
  import { type Author } from "./memories.js";
@@ -134,6 +135,16 @@ export interface DealFilter {
134
135
  /** 1–500, 100 by default; newest written first. */
135
136
  limit?: number;
136
137
  }
138
+ export interface DealSearch {
139
+ /** Words of the title, each in any letter case; a word may be a phrase. */
140
+ words: string[];
141
+ /** A title holding any of the words matches, those holding more first; otherwise every word is needed. */
142
+ anyWord?: boolean;
143
+ /** Only the copies of these CRM accounts; absent, every account's and the owner's own. */
144
+ accounts?: AccountKey[];
145
+ /** 1–500, 100 by default. */
146
+ limit?: number;
147
+ }
137
148
  export interface PipelineInput {
138
149
  name: string;
139
150
  /** `deal` when left out. */
@@ -164,6 +175,8 @@ export interface DealsStore {
164
175
  /** `deal:<id>` or `lead:<id>`; a bare id is a deal. */
165
176
  get(reference: string): Promise<Deal>;
166
177
  list(filter?: DealFilter): Promise<Deal[]>;
178
+ /** Deals and leads together, by the words of their title: the most words matched first, then the latest changed. */
179
+ find(search: DealSearch): Promise<Deal[]>;
167
180
  /** To another stage of its own pipeline; a stage whose outcome is won or lost closes it, an open one reopens it. */
168
181
  move(reference: string, input: {
169
182
  stage: string;
@@ -189,6 +202,7 @@ export declare const dealOperationsOver: (context: StoreContext) => {
189
202
  add: (input: DealInput) => Deal;
190
203
  get: (reference: string) => Deal;
191
204
  list: (filter?: DealFilter) => Deal[];
205
+ find: ({ words, anyWord, accounts, limit }: DealSearch) => Deal[];
192
206
  move: (reference: string, { stage, by }: {
193
207
  stage: string;
194
208
  by: Author;
@@ -334,6 +334,30 @@ export const dealOperationsOver = (context) => {
334
334
  .all(status, status, project, project, stage, stage, pipeline, pipeline, organization?.id ?? null, organization?.id ?? null, ...by.params, limit)
335
335
  .map(itemOf(kind));
336
336
  },
337
+ find: ({ words, anyWord, accounts, limit }) => {
338
+ const wanted = [...new Set(words.map((word) => fold(word).trim()).filter(Boolean))];
339
+ if (!wanted.length)
340
+ return [];
341
+ const most = Math.min(Math.max(limit ?? 100, 1), 500);
342
+ const within = accounts?.map(({ provider, account }) => `${provider}\u0000${account}`);
343
+ // Titles are short and a CRM holds thousands, not millions: folding them here is cheaper than a word index.
344
+ const found = DEAL_KINDS.flatMap((kind) => database
345
+ .prepare(`SELECT d.id, d.title, d.updated_at, a.provider, a.external_id AS account FROM ${TABLES[kind]} d ` +
346
+ "LEFT JOIN accounts a ON a.id = d.account_id WHERE d.deleted_at IS NULL")
347
+ .all()
348
+ .filter((row) => within === undefined || within.includes(`${row.provider}\u0000${row.account}`))
349
+ .map((row) => {
350
+ const title = fold(String(row.title));
351
+ return { kind, row, score: wanted.filter((word) => title.includes(word)).length };
352
+ })
353
+ .filter(({ score }) => (anyWord ? score > 0 : score === wanted.length)));
354
+ return found
355
+ .sort((a, b) => b.score - a.score ||
356
+ Number(b.row.updated_at) - Number(a.row.updated_at) ||
357
+ Number(b.row.id) - Number(a.row.id))
358
+ .slice(0, most)
359
+ .map(({ kind, row }) => read(`${kind}:${row.id}`));
360
+ },
337
361
  move: (reference, { stage, by }) => {
338
362
  authorActor(context, by);
339
363
  const { kind, row } = rowOf(reference);
@@ -372,6 +396,7 @@ export const dealsStoreOver = (context) => {
372
396
  add: async (input) => operations.add(input),
373
397
  get: async (reference) => operations.get(reference),
374
398
  list: async (filter) => operations.list(filter),
399
+ find: async (search) => operations.find(search),
375
400
  move: async (reference, input) => operations.move(reference, input),
376
401
  close: async (reference, input) => operations.close(reference, input),
377
402
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.588.0",
3
+ "version": "0.590.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",