@wirecat/cli-core 0.596.0 → 0.597.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/background/deliver.d.ts +7 -1
  3. package/dist/background/deliver.js +66 -10
  4. package/dist/background/telegram-html.d.ts +2 -0
  5. package/dist/background/telegram-html.js +66 -0
  6. package/dist/calendar/mcp/index.d.ts +12 -12
  7. package/dist/cli/index.d.ts +1 -1
  8. package/dist/cli/permission-keys.d.ts +10 -2
  9. package/dist/cli/preview.d.ts +3 -0
  10. package/dist/cli/preview.js +9 -5
  11. package/dist/crm/cli/index.js +71 -14
  12. package/dist/crm/index.d.ts +1 -1
  13. package/dist/crm/index.js +1 -1
  14. package/dist/crm/source.d.ts +6 -1
  15. package/dist/crm/sync.d.ts +5 -4
  16. package/dist/crm/sync.js +22 -4
  17. package/dist/crm/testing/index.d.ts +1 -1
  18. package/dist/crm/testing/index.js +1 -1
  19. package/dist/crm/testing/source-contract.js +28 -0
  20. package/dist/crm/testing/source.d.ts +8 -1
  21. package/dist/crm/testing/source.js +53 -9
  22. package/dist/crm/testing/sync-contract.js +14 -3
  23. package/dist/crm/writer.d.ts +11 -2
  24. package/dist/crm/writer.js +31 -0
  25. package/dist/crm/writes.d.ts +12 -2
  26. package/dist/crm/writes.js +93 -8
  27. package/dist/domain/crm.d.ts +29 -1
  28. package/dist/entities/deal.d.ts +16 -0
  29. package/dist/entities/deal.js +8 -0
  30. package/dist/entities/index.d.ts +12 -0
  31. package/dist/errors/reasons.js +4 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/meetings/mcp/index.d.ts +15 -15
  34. package/dist/store/driver.d.ts +2 -0
  35. package/dist/store/driver.js +2 -0
  36. package/dist/store/search-index.js +6 -1
  37. package/dist/store/sqlite/accounts.js +1 -0
  38. package/dist/store/sqlite/crm.d.ts +5 -1
  39. package/dist/store/sqlite/crm.js +52 -0
  40. package/dist/store/sqlite/deals.d.ts +5 -0
  41. package/dist/store/sqlite/deals.js +13 -0
  42. package/dist/store/sqlite/manifest.js +1 -0
  43. package/dist/store/sqlite/migrations.generated.js +6 -0
  44. package/dist/store/sqlite/note-index.d.ts +1 -1
  45. package/dist/store/sqlite/note-index.js +3 -0
  46. package/dist/store/sqlite/schema.d.ts +217 -0
  47. package/dist/store/sqlite/schema.js +18 -0
  48. package/package.json +2 -2
@@ -33,6 +33,14 @@ export declare const deal: v.StrictObjectSchema<{
33
33
  readonly account: v.StringSchema<undefined>;
34
34
  }, undefined>, undefined>;
35
35
  readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
36
+ /** A CRM copy's assignee, the identity `<account>/user/<id>`. */
37
+ readonly assignee: v.NullableSchema<v.StringSchema<undefined>, undefined>;
38
+ /** A CRM copy's own fields by code; a list field's value is its option's label, `option_id` its id. */
39
+ readonly provider_metadata: v.RecordSchema<v.StringSchema<undefined>, v.StrictObjectSchema<{
40
+ readonly label: v.NullableSchema<v.StringSchema<undefined>, undefined>;
41
+ readonly value: v.UnknownSchema;
42
+ readonly option_id: v.OptionalSchema<v.UnionSchema<[v.StringSchema<undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>, undefined>;
43
+ }, undefined>, undefined>;
36
44
  readonly people: v.ArraySchema<v.StrictObjectSchema<{
37
45
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
38
46
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -86,6 +94,14 @@ export declare const lead: v.StrictObjectSchema<{
86
94
  readonly account: v.StringSchema<undefined>;
87
95
  }, undefined>, undefined>;
88
96
  readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
97
+ /** A CRM copy's assignee, the identity `<account>/user/<id>`. */
98
+ readonly assignee: v.NullableSchema<v.StringSchema<undefined>, undefined>;
99
+ /** A CRM copy's own fields by code; a list field's value is its option's label, `option_id` its id. */
100
+ readonly provider_metadata: v.RecordSchema<v.StringSchema<undefined>, v.StrictObjectSchema<{
101
+ readonly label: v.NullableSchema<v.StringSchema<undefined>, undefined>;
102
+ readonly value: v.UnknownSchema;
103
+ readonly option_id: v.OptionalSchema<v.UnionSchema<[v.StringSchema<undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>, undefined>;
104
+ }, undefined>, undefined>;
89
105
  readonly people: v.ArraySchema<v.StrictObjectSchema<{
90
106
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
91
107
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -32,6 +32,14 @@ export const deal = v.strictObject({
32
32
  /** The CRM account it is a copy from; `null` for the owner's own. */
33
33
  account: crmAccount,
34
34
  external_id: nullableText,
35
+ /** A CRM copy's assignee, the identity `<account>/user/<id>`. */
36
+ assignee: nullableText,
37
+ /** A CRM copy's own fields by code; a list field's value is its option's label, `option_id` its id. */
38
+ provider_metadata: v.record(v.string(), v.strictObject({
39
+ label: v.nullable(v.string()),
40
+ value: v.unknown(),
41
+ option_id: v.optional(v.union([v.string(), v.array(v.string())])),
42
+ })),
35
43
  people: v.array(v.strictObject({ ...party.entries, role: v.picklist(["owner", "contact", "decision-maker"]) })),
36
44
  author: v.strictObject({
37
45
  type: v.picklist(["person", "bot"]),
@@ -262,6 +262,12 @@ export declare const ENTITIES: {
262
262
  readonly account: v.StringSchema<undefined>;
263
263
  }, undefined>, undefined>;
264
264
  readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
265
+ readonly assignee: v.NullableSchema<v.StringSchema<undefined>, undefined>;
266
+ readonly provider_metadata: v.RecordSchema<v.StringSchema<undefined>, v.StrictObjectSchema<{
267
+ readonly label: v.NullableSchema<v.StringSchema<undefined>, undefined>;
268
+ readonly value: v.UnknownSchema;
269
+ readonly option_id: v.OptionalSchema<v.UnionSchema<[v.StringSchema<undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>, undefined>;
270
+ }, undefined>, undefined>;
265
271
  readonly people: v.ArraySchema<v.StrictObjectSchema<{
266
272
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
267
273
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -311,6 +317,12 @@ export declare const ENTITIES: {
311
317
  readonly account: v.StringSchema<undefined>;
312
318
  }, undefined>, undefined>;
313
319
  readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
320
+ readonly assignee: v.NullableSchema<v.StringSchema<undefined>, undefined>;
321
+ readonly provider_metadata: v.RecordSchema<v.StringSchema<undefined>, v.StrictObjectSchema<{
322
+ readonly label: v.NullableSchema<v.StringSchema<undefined>, undefined>;
323
+ readonly value: v.UnknownSchema;
324
+ readonly option_id: v.OptionalSchema<v.UnionSchema<[v.StringSchema<undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>, undefined>;
325
+ }, undefined>, undefined>;
314
326
  readonly people: v.ArraySchema<v.StrictObjectSchema<{
315
327
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
316
328
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -215,6 +215,10 @@ const OTHERS = [
215
215
  validation("crm.stage-unknown", "a CRM item stands in a stage the store does not hold; it was not kept"),
216
216
  validation("crm.item-invalid", "a CRM item has a field the store refuses, such as a currency it does not keep"),
217
217
  validation("crm.pipeline-required", "the CRM has more than one pipeline of the kind; the write names one"),
218
+ validation("field.unknown", "no custom field of the kind has that code or label; `fields list` lists them"),
219
+ validation("field.ambiguous", "more than one custom field has that label; name one by its code"),
220
+ validation("field.unknown-option", "the list field has no option with that id or label"),
221
+ validation("field.unknown-kind", "fields are listed for deals, leads, people and organizations only"),
218
222
  validation("crm.write-unsupported", "the CRM does not take this write, or does not recognise a repeated one"),
219
223
  validation("crm.unlisted-held", "a list of every id would mark most of a kind deleted; nothing was, until --rescan"),
220
224
  validation("order.duplicate", "the shop's order with this number is already stored; change its status instead"),
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { bareCheck, type CheckWords, checkWords } from "./domain/checks.js";
2
2
  export type { CounterField, CounterObservation, CounterObservations, CounterState } from "./domain/counters.js";
3
3
  export { COUNTER_FIELDS, counterValue, observedCounters } from "./domain/counters.js";
4
- export { CRM_KINDS, type CrmItem, type CrmKind, type CrmOrganization, type CrmPerson, type CrmPipeline, type CrmRecords, type CrmStage, isItemKind, } from "./domain/crm.js";
4
+ export { CRM_KINDS, type CrmField, type CrmItem, type CrmKind, type CrmOrganization, type CrmPerson, type CrmPipeline, type CrmRecords, type CrmStage, type CrmValue, isItemKind, } from "./domain/crm.js";
5
5
  export { DEAL_KINDS, DEAL_ROLES, DEAL_STATUSES, type DealKind, type DealRole, type DealStatus, STAGE_OUTCOMES, type StageOutcome, } from "./domain/deals.js";
6
6
  export { FINANCE_REVIEWS, type FinanceReview, INVOICE_DIRECTIONS, INVOICE_STATUSES, type InvoiceDirection, type InvoiceStatus, MONEY_DIRECTIONS, type MoneyDirection, } from "./domain/finance.js";
7
7
  export { type FormattedText, type HtmlFormatting, type MarkdownFormatting, type TextSpan, validateFormattedText, } from "./domain/formatting.js";
@@ -38,6 +38,7 @@ export declare const meetingTools: ({
38
38
  max_read_bytes?: undefined;
39
39
  cursor?: undefined;
40
40
  } | {
41
+ limit?: undefined;
41
42
  event_id?: undefined;
42
43
  series_id?: undefined;
43
44
  page?: undefined;
@@ -53,11 +54,11 @@ export declare const meetingTools: ({
53
54
  format?: undefined;
54
55
  transcript_id?: undefined;
55
56
  query?: undefined;
56
- limit?: undefined;
57
57
  scan_limit?: undefined;
58
58
  max_read_bytes?: undefined;
59
59
  cursor?: undefined;
60
60
  } | {
61
+ limit?: undefined;
61
62
  event_id?: undefined;
62
63
  series_id?: undefined;
63
64
  page?: undefined;
@@ -75,11 +76,11 @@ export declare const meetingTools: ({
75
76
  format?: undefined;
76
77
  transcript_id?: undefined;
77
78
  query?: undefined;
78
- limit?: undefined;
79
79
  scan_limit?: undefined;
80
80
  max_read_bytes?: undefined;
81
81
  cursor?: undefined;
82
82
  } | {
83
+ limit?: undefined;
83
84
  event_id?: undefined;
84
85
  series_id?: undefined;
85
86
  page?: undefined;
@@ -101,7 +102,6 @@ export declare const meetingTools: ({
101
102
  minimum: number;
102
103
  };
103
104
  query?: undefined;
104
- limit?: undefined;
105
105
  scan_limit?: undefined;
106
106
  max_read_bytes?: undefined;
107
107
  cursor?: undefined;
@@ -188,6 +188,7 @@ export declare const meetingTools: ({
188
188
  max_read_bytes?: undefined;
189
189
  cursor?: undefined;
190
190
  } | {
191
+ limit?: undefined;
191
192
  event_id?: undefined;
192
193
  series_id?: undefined;
193
194
  page?: undefined;
@@ -203,11 +204,11 @@ export declare const meetingTools: ({
203
204
  format?: undefined;
204
205
  transcript_id?: undefined;
205
206
  query?: undefined;
206
- limit?: undefined;
207
207
  scan_limit?: undefined;
208
208
  max_read_bytes?: undefined;
209
209
  cursor?: undefined;
210
210
  } | {
211
+ limit?: undefined;
211
212
  event_id?: undefined;
212
213
  series_id?: undefined;
213
214
  page?: undefined;
@@ -225,11 +226,11 @@ export declare const meetingTools: ({
225
226
  format?: undefined;
226
227
  transcript_id?: undefined;
227
228
  query?: undefined;
228
- limit?: undefined;
229
229
  scan_limit?: undefined;
230
230
  max_read_bytes?: undefined;
231
231
  cursor?: undefined;
232
232
  } | {
233
+ limit?: undefined;
233
234
  event_id?: undefined;
234
235
  series_id?: undefined;
235
236
  page?: undefined;
@@ -251,7 +252,6 @@ export declare const meetingTools: ({
251
252
  minimum: number;
252
253
  };
253
254
  query?: undefined;
254
- limit?: undefined;
255
255
  scan_limit?: undefined;
256
256
  max_read_bytes?: undefined;
257
257
  cursor?: undefined;
@@ -338,6 +338,7 @@ export declare const meetingTools: ({
338
338
  max_read_bytes?: undefined;
339
339
  cursor?: undefined;
340
340
  } | {
341
+ limit?: undefined;
341
342
  event_id?: undefined;
342
343
  series_id?: undefined;
343
344
  page?: undefined;
@@ -353,11 +354,11 @@ export declare const meetingTools: ({
353
354
  format?: undefined;
354
355
  transcript_id?: undefined;
355
356
  query?: undefined;
356
- limit?: undefined;
357
357
  scan_limit?: undefined;
358
358
  max_read_bytes?: undefined;
359
359
  cursor?: undefined;
360
360
  } | {
361
+ limit?: undefined;
361
362
  event_id?: undefined;
362
363
  series_id?: undefined;
363
364
  page?: undefined;
@@ -375,11 +376,11 @@ export declare const meetingTools: ({
375
376
  format?: undefined;
376
377
  transcript_id?: undefined;
377
378
  query?: undefined;
378
- limit?: undefined;
379
379
  scan_limit?: undefined;
380
380
  max_read_bytes?: undefined;
381
381
  cursor?: undefined;
382
382
  } | {
383
+ limit?: undefined;
383
384
  event_id?: undefined;
384
385
  series_id?: undefined;
385
386
  page?: undefined;
@@ -401,7 +402,6 @@ export declare const meetingTools: ({
401
402
  minimum: number;
402
403
  };
403
404
  query?: undefined;
404
- limit?: undefined;
405
405
  scan_limit?: undefined;
406
406
  max_read_bytes?: undefined;
407
407
  cursor?: undefined;
@@ -488,6 +488,7 @@ export declare const meetingTools: ({
488
488
  max_read_bytes?: undefined;
489
489
  cursor?: undefined;
490
490
  } | {
491
+ limit?: undefined;
491
492
  event_id?: undefined;
492
493
  series_id?: undefined;
493
494
  page?: undefined;
@@ -503,11 +504,11 @@ export declare const meetingTools: ({
503
504
  format?: undefined;
504
505
  transcript_id?: undefined;
505
506
  query?: undefined;
506
- limit?: undefined;
507
507
  scan_limit?: undefined;
508
508
  max_read_bytes?: undefined;
509
509
  cursor?: undefined;
510
510
  } | {
511
+ limit?: undefined;
511
512
  event_id?: undefined;
512
513
  series_id?: undefined;
513
514
  page?: undefined;
@@ -525,11 +526,11 @@ export declare const meetingTools: ({
525
526
  format?: undefined;
526
527
  transcript_id?: undefined;
527
528
  query?: undefined;
528
- limit?: undefined;
529
529
  scan_limit?: undefined;
530
530
  max_read_bytes?: undefined;
531
531
  cursor?: undefined;
532
532
  } | {
533
+ limit?: undefined;
533
534
  event_id?: undefined;
534
535
  series_id?: undefined;
535
536
  page?: undefined;
@@ -551,7 +552,6 @@ export declare const meetingTools: ({
551
552
  minimum: number;
552
553
  };
553
554
  query?: undefined;
554
- limit?: undefined;
555
555
  scan_limit?: undefined;
556
556
  max_read_bytes?: undefined;
557
557
  cursor?: undefined;
@@ -638,6 +638,7 @@ export declare const meetingTools: ({
638
638
  max_read_bytes?: undefined;
639
639
  cursor?: undefined;
640
640
  } | {
641
+ limit?: undefined;
641
642
  event_id?: undefined;
642
643
  series_id?: undefined;
643
644
  page?: undefined;
@@ -653,11 +654,11 @@ export declare const meetingTools: ({
653
654
  format?: undefined;
654
655
  transcript_id?: undefined;
655
656
  query?: undefined;
656
- limit?: undefined;
657
657
  scan_limit?: undefined;
658
658
  max_read_bytes?: undefined;
659
659
  cursor?: undefined;
660
660
  } | {
661
+ limit?: undefined;
661
662
  event_id?: undefined;
662
663
  series_id?: undefined;
663
664
  page?: undefined;
@@ -675,11 +676,11 @@ export declare const meetingTools: ({
675
676
  format?: undefined;
676
677
  transcript_id?: undefined;
677
678
  query?: undefined;
678
- limit?: undefined;
679
679
  scan_limit?: undefined;
680
680
  max_read_bytes?: undefined;
681
681
  cursor?: undefined;
682
682
  } | {
683
+ limit?: undefined;
683
684
  event_id?: undefined;
684
685
  series_id?: undefined;
685
686
  page?: undefined;
@@ -701,7 +702,6 @@ export declare const meetingTools: ({
701
702
  minimum: number;
702
703
  };
703
704
  query?: undefined;
704
- limit?: undefined;
705
705
  scan_limit?: undefined;
706
706
  max_read_bytes?: undefined;
707
707
  cursor?: undefined;
@@ -46,3 +46,5 @@ export type OpenDatabase = (path: string) => CacheDatabase;
46
46
  * `foreign_keys` is off by default in SQLite, which surprises everyone once.
47
47
  */
48
48
  export declare const PRAGMAS = "\n PRAGMA busy_timeout = 5000;\n PRAGMA journal_mode = WAL;\n PRAGMA foreign_keys = ON;\n";
49
+ /** A read-only open sets `query_only`: a search over it reads what is indexed and leaves building it to a writable open. */
50
+ export declare const isQueryOnly: (database: CacheDatabase) => boolean;
@@ -36,3 +36,5 @@ export const PRAGMAS = `
36
36
  PRAGMA journal_mode = WAL;
37
37
  PRAGMA foreign_keys = ON;
38
38
  `;
39
+ /** A read-only open sets `query_only`: a search over it reads what is indexed and leaves building it to a writable open. */
40
+ export const isQueryOnly = (database) => Number(Object.values(database.prepare("PRAGMA query_only").get() ?? {})[0]) === 1;
@@ -1,3 +1,4 @@
1
+ import { isQueryOnly } from "./driver.js";
1
2
  import { CORPORA, corpusIndexState, drainCorpus } from "./sqlite/note-index.js";
2
3
  import * as searchIndex from "./sqlite/search-index.js";
3
4
  import * as stems from "./sqlite/stems.js";
@@ -11,9 +12,13 @@ export const searchIndexStoreOver = (context) => {
11
12
  knownTerms: async (terms) => words.knownTerms(context, terms),
12
13
  termCandidates: async (trigrams, lengths) => words.termCandidates(context, trigrams, lengths),
13
14
  searchIndexState: async () => searchIndex.searchIndexState(database),
14
- fillSearchIndex: async ({ until } = {}) => searchIndex.fillSearchIndex(database, { now: context.now, ...(until ? { until } : {}) }),
15
+ fillSearchIndex: async ({ until } = {}) => isQueryOnly(database)
16
+ ? { normalized: 0, indexed: 0, terms: 0 }
17
+ : searchIndex.fillSearchIndex(database, { now: context.now, ...(until ? { until } : {}) }),
15
18
  stemsState: async () => stems.stemsState(database),
16
19
  fillStems: async ({ until } = {}) => {
20
+ if (isQueryOnly(database))
21
+ return { stemmed: 0, drained: 0 };
17
22
  const filled = fillStems(database, { now: context.now, ...(until ? { until } : {}) });
18
23
  // Read first: every search asks, and an idle drain must not take the write lock.
19
24
  const transcripts = corpusIndexState(database, CORPORA.transcript);
@@ -114,6 +114,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
114
114
  // The inbox project and its tasks are the owner's; only the tie to the account goes.
115
115
  orm.update(projects).set({ accountId: null }).where(eq(projects.accountId, accountKey)).run();
116
116
  orm.run(sql `DELETE FROM files WHERE account_id = ${accountKey}`);
117
+ orm.run(sql `DELETE FROM crm_fields WHERE account_id = ${accountKey}`);
117
118
  // An entity made from a record is the owner's and stays; only what the source said goes.
118
119
  const recordsOf = sql `(SELECT id FROM records WHERE account_id = ${accountKey})`;
119
120
  orm.run(sql `DELETE FROM record_relations WHERE record_id IN ${recordsOf} OR target_id IN ${recordsOf}`);
@@ -1,4 +1,4 @@
1
- import type { CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline } from "../../domain/crm.js";
1
+ import type { CrmField, CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline } from "../../domain/crm.js";
2
2
  import { type DealKind } from "../../domain/deals.js";
3
3
  import type { AccountPeople } from "../people.js";
4
4
  import type { AccountKey } from "../types.js";
@@ -27,6 +27,10 @@ export interface CrmCopies {
27
27
  savePeople(key: AccountKey, people: CrmPerson[]): Promise<CrmSaved>;
28
28
  /** Companies, each an organization of its own made on first sight, kept apart from any of the same name. */
29
29
  saveOrganizations(key: AccountKey, organizations: CrmOrganization[]): Promise<CrmSaved>;
30
+ /** One kind's whole list of the CRM's own fields: one it lacks is marked deleted, one listed again comes back. */
31
+ saveFields(key: AccountKey, kind: CrmKind, fields: CrmField[]): Promise<CrmSaved>;
32
+ /** The kind's fields as last synced, in the CRM's order; none before the first sync. */
33
+ fields(key: AccountKey, kind: CrmKind): Promise<CrmField[]>;
30
34
  /** Marks deleted what the CRM said it deleted. Contacts stay: an identity has no deleted mark. Answers how many. */
31
35
  remove(key: AccountKey, kind: CrmKind, externalIds: string[]): Promise<number>;
32
36
  /**
@@ -114,6 +114,55 @@ export const crmCopiesOver = (context, people) => {
114
114
  .run(pipelineId, stage.name, stage.folded, stage.position, stage.outcome, stage.externalId, at, at);
115
115
  }
116
116
  };
117
+ // Unlike pipelines, an empty list does mark every field deleted: a CRM with no custom fields is an ordinary answer.
118
+ const saveFields = (key, kind, fields) => write(database, () => {
119
+ const accountId = account(key);
120
+ const at = now();
121
+ const skipped = [];
122
+ const listed = new Set();
123
+ const upsert = database.prepare("INSERT INTO crm_fields (account_id, kind, code, label, type, multiple, filterable, options, position, " +
124
+ "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (account_id, kind, code) DO " +
125
+ "UPDATE SET label = excluded.label, type = excluded.type, multiple = excluded.multiple, " +
126
+ "filterable = excluded.filterable, options = excluded.options, position = excluded.position, " +
127
+ "updated_at = excluded.updated_at, deleted_at = NULL");
128
+ for (const field of fields) {
129
+ const code = typeof field.code === "string" ? field.code.trim() : "";
130
+ if (!code || listed.has(code)) {
131
+ skipped.push({
132
+ externalId: code,
133
+ reason: "crm.item-invalid",
134
+ message: code ? `${kind} field ${code} is listed twice` : `a ${kind} field has no code`,
135
+ });
136
+ continue;
137
+ }
138
+ listed.add(code);
139
+ const options = field.options?.map((option) => ({ id: String(option.id), label: String(option.label) }));
140
+ upsert.run(accountId, kind, code, textOf(field.label, 200), textOf(field.type, 80) ?? "string", field.multiple ? 1 : 0, field.filterable ? 1 : 0, options ? JSON.stringify(options) : null, listed.size, at, at);
141
+ }
142
+ const mark = database.prepare("UPDATE crm_fields SET deleted_at = ?, updated_at = ? WHERE id = ?");
143
+ for (const row of database
144
+ .prepare("SELECT id, code FROM crm_fields WHERE account_id = ? AND kind = ? AND deleted_at IS NULL")
145
+ .all(accountId, kind))
146
+ if (!listed.has(String(row.code)))
147
+ mark.run(at, at, Number(row.id));
148
+ return { saved: listed.size, skipped };
149
+ });
150
+ const fieldsOf = (key, kind) => {
151
+ const accountId = findAccountPk(context, key);
152
+ if (accountId === undefined)
153
+ return [];
154
+ return database
155
+ .prepare("SELECT * FROM crm_fields WHERE account_id = ? AND kind = ? AND deleted_at IS NULL ORDER BY position, id")
156
+ .all(accountId, kind)
157
+ .map((row) => ({
158
+ code: String(row.code),
159
+ label: row.label == null ? null : String(row.label),
160
+ type: String(row.type),
161
+ multiple: Number(row.multiple) === 1,
162
+ filterable: Number(row.filterable) === 1,
163
+ ...(row.options == null ? {} : { options: JSON.parse(String(row.options)) }),
164
+ }));
165
+ };
117
166
  const personIdOf = (key, externalId) => {
118
167
  const row = database
119
168
  .prepare("SELECT l.person_id FROM identities i JOIN identity_links l ON l.identity_id = i.id " +
@@ -158,6 +207,7 @@ export const crmCopiesOver = (context, people) => {
158
207
  const metadata = {
159
208
  ...(item.metadata ?? {}),
160
209
  ...(item.url ? { url: item.url } : {}),
210
+ ...(item.assignee ? { assignee: crmIdentityId(key, item.assignee, "user") } : {}),
161
211
  ...(item.closedReason && status !== "open" ? { closed_reason: textOf(item.closedReason, 2000) } : {}),
162
212
  };
163
213
  const values = {
@@ -304,6 +354,8 @@ export const crmCopiesOver = (context, people) => {
304
354
  return { saved: contacts.length, skipped: [] };
305
355
  },
306
356
  saveOrganizations: async (key, organizations) => saveOrganizations(key, organizations),
357
+ saveFields: async (key, kind, fields) => saveFields(key, kind, fields),
358
+ fields: async (key, kind) => fieldsOf(key, kind),
307
359
  remove: async (key, kind, externalIds) => write(database, () => {
308
360
  const gone = new Set(externalIds);
309
361
  return markDeleted(kind, stored(key, kind)
@@ -1,3 +1,4 @@
1
+ import type { CrmValue } from "../../domain/crm.js";
1
2
  import { type DealKind, type DealRole, type DealStatus, type StageOutcome } from "../../domain/deals.js";
2
3
  import type { AccountKey } from "../types.js";
3
4
  import { type ProposedByFilter } from "./actors.js";
@@ -77,6 +78,10 @@ export interface Deal {
77
78
  account: string;
78
79
  } | null;
79
80
  externalId: string | null;
81
+ /** A CRM copy's assignee, as the identity `<account>/user/<id>`. */
82
+ assignee: string | null;
83
+ /** A CRM copy's own fields by code, as the CRM labels them; empty on the owner's own. */
84
+ providerMetadata: Record<string, CrmValue>;
80
85
  people: DealPerson[];
81
86
  author: {
82
87
  type: "person" | "bot";
@@ -27,6 +27,17 @@ const jsonField = (metadata, key) => {
27
27
  return null;
28
28
  }
29
29
  };
30
+ const isValue = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && "label" in value && "value" in value;
31
+ const providerMetadataOf = (metadata) => {
32
+ if (metadata == null)
33
+ return {};
34
+ try {
35
+ return Object.fromEntries(Object.entries(JSON.parse(String(metadata))).filter(([, value]) => isValue(value)));
36
+ }
37
+ catch {
38
+ return {};
39
+ }
40
+ };
30
41
  const accountOf = (provider, external) => provider == null ? null : { provider: String(provider), account: String(external) };
31
42
  export const dealOperationsOver = (context) => {
32
43
  const { database, now } = context;
@@ -68,6 +79,8 @@ export const dealOperationsOver = (context) => {
68
79
  deal: row.deal_id == null ? null : `deal:${row.deal_id}`,
69
80
  account: accountOf(row.account_provider, row.account_external_id),
70
81
  externalId: row.external_id == null ? null : String(row.external_id),
82
+ assignee: jsonField(row.metadata, "assignee"),
83
+ providerMetadata: providerMetadataOf(row.metadata),
71
84
  people: peopleOf(kind, fields.id),
72
85
  author: authorOf(row),
73
86
  model: row.model == null ? null : String(row.model),
@@ -110,6 +110,7 @@ export const MANIFEST = [
110
110
  { name: "20261106010504_version-52-deals", version: 52, minCompatible: 34 },
111
111
  { name: "20261106010507_version-52-deals-rules", version: 52, minCompatible: 34 },
112
112
  { name: "20261106165009_version-53-crm-pipelines", version: 53, minCompatible: 34 },
113
+ { name: "20261106225547_version-54-crm-fields", version: 54, minCompatible: 34 },
113
114
  ];
114
115
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
115
116
  const migrations = [];
@@ -1042,5 +1042,11 @@ export const GENERATED = [
1042
1042
  "DROP INDEX IF EXISTS `pipelines_by_account_id_external_id`;",
1043
1043
  "CREATE UNIQUE INDEX `pipelines_by_account_id_kind_external_id` ON `pipelines` (`account_id`,`kind`,`external_id`) WHERE account_id IS NOT NULL AND external_id IS NOT NULL;"
1044
1044
  ]
1045
+ },
1046
+ {
1047
+ "name": "20261106225547_version-54-crm-fields",
1048
+ "statements": [
1049
+ "CREATE TABLE `crm_fields` (\n\t`id` integer PRIMARY KEY,\n\t`account_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`code` text NOT NULL,\n\t`label` text,\n\t`type` text NOT NULL,\n\t`multiple` integer NOT NULL,\n\t`filterable` integer NOT NULL,\n\t`options` text,\n\t`position` integer NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\t`deleted_at` integer,\n\tCONSTRAINT `fk_crm_fields_account_id_accounts_id_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`),\n\tCONSTRAINT `crm_fields_account_id_kind_code_unique` UNIQUE(`account_id`,`kind`,`code`)\n);"
1050
+ ]
1045
1051
  }
1046
1052
  ];
@@ -1,5 +1,5 @@
1
1
  import { type Stemmer, type Stemmers } from "../../search/stem.js";
2
- import type { CacheDatabase } from "../driver.js";
2
+ import { type CacheDatabase } from "../driver.js";
3
3
  import type { Where } from "./query-filter.js";
4
4
  export interface NoteIndexState {
5
5
  /** Rows written since they were indexed. */
@@ -2,6 +2,7 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { CHUNK_CHARS, chunkHash, splitSections, splitText } from "../../conversations/chunks.js";
3
3
  import { analyzerIdentity, DEFAULT_STEMMERS } from "../../search/stem.js";
4
4
  import { taskStemsOf, taskWordsOf } from "../../search/task-similarity.js";
5
+ import { isQueryOnly } from "../driver.js";
5
6
  import { normalize } from "../normalize.js";
6
7
  import { dropDocumentPicture } from "./document-pictures.js";
7
8
  import { int } from "./events.js";
@@ -248,6 +249,8 @@ const writer = (database, corpus, stemmer) => {
248
249
  * small, so a change of stemmer choices simply queues every row again instead of waiting for `store rebuild`.
249
250
  */
250
251
  export const drainCorpus = (database, corpus, stemmerFor, { batch = 200, until = () => false } = {}) => {
252
+ if (isQueryOnly(database))
253
+ return 0;
251
254
  // Pieces written before they carried their account get it, once: purge and vector search filter by it.
252
255
  if (corpus.type === "email" || corpus.type === "document")
253
256
  database