@wirecat/cli-core 0.393.0 → 0.394.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
@@ -5,6 +5,22 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## 0.394.0 — 23.10.2026
9
+
10
+ ### Added
11
+
12
+ - Statistics commands include examples in help and command discovery, with guidance for reusing an exact evidence selection.
13
+
14
+ - Store commands include concrete examples in help and command discovery.
15
+
16
+ - **`contacts prepare <person>`: what to know before talking to someone.** What `contacts context` gives, plus the
17
+ one-to-one chats where their message is the newest the store holds, open tasks assigned to them, accepted facts
18
+ about them and their projects, your notes about them (withheld when the profile denies `contacts.notes.list`), and
19
+ their recent meetings — each with its reference, under one byte budget, with `prepare.coverage` saying what each
20
+ section left out and why. Tasks of another account of the same messenger, and facts on a project bound to one, stay
21
+ out. It reads messages, so `messages: deny` refuses it, as it does `contacts context`. `personPreparation` in
22
+ services and `people.preparation` on the store.
23
+
8
24
  ## 0.393.0 — 23.10.2026
9
25
 
10
26
  ### Added
@@ -12,6 +12,7 @@ import { kindsOf } from "../../services/inbox.js";
12
12
  import { dateWindow } from "../../services/moment.js";
13
13
  import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
14
14
  import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
15
+ import { commandAt } from "../steps.js";
15
16
  import { jobsCommand } from "./backfill-command.js";
16
17
  import { messengerContext } from "./context.js";
17
18
  import { admitPassword, linesOf, openFolder, runFileFor, saveManifest, sealRun, writeChanges } from "./export-folder.js";
@@ -30,6 +31,33 @@ export const storeCommand = (messenger) => {
30
31
  .addCommand(clearCommand(messenger));
31
32
  for (const command of storeMaintenanceCommands(messenger))
32
33
  store.addCommand(command);
34
+ const examples = {
35
+ backup: "./backup.db",
36
+ check: "",
37
+ clear: "--left --dry-run",
38
+ "copies delete": "messages_copy_1",
39
+ decrypt: "./backup.enc --output ./restored.db",
40
+ export: '"Book club" --output ./messages.jsonl',
41
+ "gaps plan": '"Book club"',
42
+ "gaps repair": '"Book club" --limit 100',
43
+ info: "",
44
+ "jobs cancel": "job-101",
45
+ "jobs clear": "",
46
+ "jobs list": "",
47
+ "jobs retry": "--failed",
48
+ "jobs show": "",
49
+ migrate: "",
50
+ reindex: "",
51
+ repair: "--dry-run",
52
+ reset: "",
53
+ restore: "./backup.db",
54
+ status: '"Book club"',
55
+ };
56
+ for (const [path, args] of Object.entries(examples)) {
57
+ const command = commandAt(store, path);
58
+ if (command)
59
+ annotate(command, { examples: [`${messenger.app.command} personal store ${path} ${args}`.trimEnd()] });
60
+ }
33
61
  return store;
34
62
  };
35
63
  /**
@@ -137,6 +137,35 @@ export const contactsCommand = (messenger) => {
137
137
  if (hasMore)
138
138
  context.renderer.note("cut at --limit; a larger one shows more");
139
139
  });
140
+ annotate(contacts.command("prepare"), {
141
+ effects: REMOTE_READ,
142
+ examples: [`${messenger.app.command} contacts prepare Ana`],
143
+ })
144
+ .summary("what to know before you talk to someone, each item with its reference")
145
+ .description("before you talk to someone: what contacts context gives, plus the one-to-one chats where they wrote last, " +
146
+ "open tasks, accepted facts, your notes and their recent meetings, each with its reference; never connects")
147
+ .argument("<person>", "their id, @username, or part of their name")
148
+ .option("--limit <n>", `at most this many messages, and items in each section; ${CONTEXT_MESSAGES} if not given`, positiveCount("--limit"))
149
+ .option("--since-time <time>", "no message or meeting older than this ISO 8601 time, or 2h / 1d ago")
150
+ .action(async function (person) {
151
+ const { limit, sinceTime } = this.opts();
152
+ const context = messengerContext(this, messenger);
153
+ const window = dateWindow(sinceTime === undefined ? {} : { sinceTime });
154
+ const found = await context.withServices((services) => services.people.prepare(person, {
155
+ messages: limit ?? CONTEXT_MESSAGES,
156
+ notes: levelFor(context.settings.permissions, "contacts.notes.list").level !== "deny",
157
+ ...(window.since === undefined ? {} : { since: window.since }),
158
+ }));
159
+ const { hasMore, ...rest } = found;
160
+ renderAnswer(this, context, itemOf(snakeKeys(rest), {
161
+ next: syncsFirst(messenger)
162
+ ? found.notRead
163
+ .filter((chat) => chat.provider === messenger.provider)
164
+ .map((chat) => nextStep("messages.sync", { args: { chat: chat.chatId } }))
165
+ : [],
166
+ meta: { ...window.meta, ...(hasMore ? { truncated: true } : {}) },
167
+ }));
168
+ });
140
169
  annotate(contacts.command("timeline"), { effects: REMOTE_READ })
141
170
  .description("everything one person took part in, in every messenger linked to them — messages they wrote or were " +
142
171
  "mentioned in, chats, mail, meetings, tasks — newest first, from the store; never connects")
@@ -12,6 +12,7 @@ import { EVENTS_DAYS } from "../../services/chats.js";
12
12
  import { dateWindow } from "../../services/moment.js";
13
13
  import { destroysNothing, LOCAL_WRITE } from "../effects.js";
14
14
  import { renderAnswer } from "../envelope.js";
15
+ import { commandAt } from "../steps.js";
15
16
  import { adminStatisticsCommand } from "./admin-statistics-command.js";
16
17
  import { statsCommand as chatStatsCommand } from "./chats-stats-command.js";
17
18
  import { messengerContext } from "./context.js";
@@ -84,5 +85,30 @@ export const statsCommand = (messenger, loadRenderer = chartRenderer) => {
84
85
  context.renderer.note("partial data — the chart shows lower bounds");
85
86
  renderAnswer(this, context, chartAnswer(chart, chartFile, window.meta));
86
87
  });
88
+ const examples = {
89
+ charts: '"Book club" --output ./activity.svg',
90
+ "chats newcomers": '"Book club"',
91
+ "chats retention": '"Book club"',
92
+ "chats show": '"Book club"',
93
+ "contacts evidence": '101 --selection "$selection" --component messages',
94
+ "contacts responses": '--chat "Project room"',
95
+ "contacts top": '--chat "Book club" --limit 10',
96
+ "messages counters refresh": '--chat "Book club" --dry-run',
97
+ "messages counters show": '--chat "Book club" --limit 10',
98
+ "messages discussion": '--chat "Book club"',
99
+ "messages evidence": '"$message" --selection "$selection" --component reactions',
100
+ "messages show": '--chat "Book club" --by day',
101
+ "messages top": '--chat "Book club" --limit 10',
102
+ "messages unanswered": '--chat "Project room"',
103
+ "tasks show": '--chat "Project room"',
104
+ };
105
+ for (const [path, args] of Object.entries(examples)) {
106
+ const command = commandAt(stats, path);
107
+ if (command)
108
+ annotate(command, { examples: [`${messenger.app.command} personal stats ${path} ${args}`] });
109
+ }
110
+ for (const target of ["messages", "contacts"]) {
111
+ commandAt(stats, `${target} evidence`)?.addHelpText("after", `\nSet selection to drilldown.selection from stats ${target} top; copy the matching row's ${target === "messages" ? "message locator into message" : "person id"}.\n`);
112
+ }
87
113
  return stats;
88
114
  };
@@ -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;
@@ -450,7 +450,8 @@ export const keyForCommand = (path) => {
450
450
  return path.join(".");
451
451
  if (top === "messages" && next === "sync")
452
452
  return "messages";
453
- if (SHOW_MESSAGES.has(top) || (top === "contacts" && (next === "context" || next === "timeline")))
453
+ if (SHOW_MESSAGES.has(top) ||
454
+ (top === "contacts" && (next === "context" || next === "prepare" || next === "timeline")))
454
455
  return "messages";
455
456
  if (RESOURCES.includes(top))
456
457
  return path.join(".");
@@ -80,6 +80,8 @@ export type { ContactSync, PeopleService } from "./people.js";
80
80
  export { peopleService, phoneOf } from "./people.js";
81
81
  export type { ContextMessage, ContextOptions, NotReadReason, PersonContext, SharedChat, } from "./person-context.js";
82
82
  export { CONTEXT_BYTES, CONTEXT_MESSAGES, identityIn, personContext } from "./person-context.js";
83
+ export type { PersonPreparation, WaitingDialog } from "./person-preparation.js";
84
+ export { PREPARE_BYTES, personPreparation } from "./person-preparation.js";
83
85
  export type { PersonTimeline, Scope, TimelineItem, TimelineOptions } from "./person-timeline.js";
84
86
  export { personTimeline, SCOPES, TIMELINE_ITEMS, TIMELINE_MAX } from "./person-timeline.js";
85
87
  export type { RankedRow, RankingFound, RankingQuery, RankingsService } from "./rankings.js";
@@ -57,6 +57,7 @@ export { searchStore } from "./messages-archive-search.js";
57
57
  export { moderationService } from "./moderation.js";
58
58
  export { peopleService, phoneOf } from "./people.js";
59
59
  export { CONTEXT_BYTES, CONTEXT_MESSAGES, identityIn, personContext } from "./person-context.js";
60
+ export { PREPARE_BYTES, personPreparation } from "./person-preparation.js";
60
61
  export { personTimeline, SCOPES, TIMELINE_ITEMS, TIMELINE_MAX } from "./person-timeline.js";
61
62
  export { rankingsService } from "./rankings.js";
62
63
  export { searchRecordOf } from "./search-record.js";
@@ -5,6 +5,7 @@ import type { PersonRecord } from "../store/store.js";
5
5
  import type { PageWindow } from "./chats.js";
6
6
  import { type ServiceDeps } from "./deps.js";
7
7
  import { type ContextOptions, type Detail, type PersonContext, type PersonMessages } from "./person-context.js";
8
+ import { type PersonPreparation } from "./person-preparation.js";
8
9
  import { type PersonTimeline, type TimelineOptions } from "./person-timeline.js";
9
10
  export interface ContactSync {
10
11
  added: number;
@@ -52,6 +53,10 @@ export interface PeopleService {
52
53
  }>>;
53
54
  /** What the store holds about them, across every messenger linked to them; never connects. */
54
55
  context(person: string, options?: ContextOptions): Promise<PersonContext>;
56
+ /** `context` and what to know before talking to them: chats waiting on you, tasks, facts, notes, meetings. */
57
+ prepare(person: string, options?: ContextOptions & {
58
+ notes?: boolean;
59
+ }): Promise<PersonPreparation>;
55
60
  /** Everything they took part in, from the store's involvement index; never connects. */
56
61
  timeline(person: string, options?: TimelineOptions): Promise<PersonTimeline>;
57
62
  /**
@@ -8,6 +8,7 @@ import { newOperationId } from "../sends/send-id.js";
8
8
  import { fromStore, storeIfOpen } from "./deps.js";
9
9
  import { storedChatId } from "./message-resolution.js";
10
10
  import { CHAT_MESSAGES, identityIn, personContext, personMessages, } from "./person-context.js";
11
+ import { personPreparation } from "./person-preparation.js";
11
12
  import { personProfile } from "./person-profile.js";
12
13
  import { personTimeline } from "./person-timeline.js";
13
14
  export const peopleService = (deps) => {
@@ -75,6 +76,7 @@ export const peopleService = (deps) => {
75
76
  },
76
77
  profile: (person) => personProfile(deps, person),
77
78
  context: async (person, options) => personContext(await deps.store(), await deps.account(), person, options),
79
+ prepare: async (person, options) => personPreparation(await deps.store(), await deps.account(), person, options),
78
80
  timeline: async (person, options) => personTimeline(await deps.store(), await deps.account(), person, options),
79
81
  messagesIn: async (person, { chats, limit, detail, fetch = false }) => {
80
82
  const store = await deps.store();
@@ -1,5 +1,5 @@
1
- import type { ChatKind, Id, Provider } from "../domain/models.js";
2
- import type { AccountKey, IdentityRef, MessageStore, PersonRecord } from "../store/store.js";
1
+ import type { ChatKind, Id, Message, Provider } from "../domain/models.js";
2
+ import type { AccountKey, IdentityRef, LinkedIdentity, MessageStore, PersonRecord } from "../store/store.js";
3
3
  export declare const CONTEXT_MESSAGES = 10;
4
4
  export declare const CONTEXT_BYTES: number;
5
5
  export interface ContextMessage {
@@ -60,6 +60,16 @@ export interface ContextOptions {
60
60
  /** ms: nothing older. */
61
61
  since?: number;
62
62
  }
63
+ export declare const toContext: (key: AccountKey, message: Message, chatTitle: string | null) => ContextMessage;
64
+ /**
65
+ * The accounts each identity is read in: for the messenger asked, only the account asked about — a
66
+ * second account of the same messenger is somebody else's view; for a linked messenger, every account
67
+ * of this store that saw that identity.
68
+ */
69
+ export declare const readsOf: (record: PersonRecord, asked: AccountKey) => {
70
+ key: AccountKey;
71
+ identity: LinkedIdentity;
72
+ }[];
63
73
  /**
64
74
  * What the store knows about one person, across every identity linked to them. Reads the store
65
75
  * only: nothing is fetched, nothing marked read. Identity is by id, never by name.
@@ -4,7 +4,7 @@ import { pickPerson } from "../resolve.js";
4
4
  import { messageOfEmail } from "../store/store.js";
5
5
  export const CONTEXT_MESSAGES = 10;
6
6
  export const CONTEXT_BYTES = 64 * 1024;
7
- const toContext = (key, message, chatTitle) => ({
7
+ export const toContext = (key, message, chatTitle) => ({
8
8
  locator: formatLocator({ ...key, chat: message.chatId, message: message.id }),
9
9
  provider: key.provider,
10
10
  account: key.account,
@@ -49,7 +49,7 @@ const newestFirst = (a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp);
49
49
  * second account of the same messenger is somebody else's view; for a linked messenger, every account
50
50
  * of this store that saw that identity.
51
51
  */
52
- const readsOf = (record, asked) => record.identities.flatMap((identity) => (identity.provider === asked.provider ? [asked.account] : identity.accounts)
52
+ export const readsOf = (record, asked) => record.identities.flatMap((identity) => (identity.provider === asked.provider ? [asked.account] : identity.accounts)
53
53
  .filter((account) => identity.accounts.includes(account))
54
54
  .map((account) => ({ key: { provider: identity.provider, account }, identity })));
55
55
  /**
@@ -0,0 +1,66 @@
1
+ import type { Id, Provider } from "../domain/models.js";
2
+ import type { AccountKey, MessageStore, PreparationFact, PreparationNote, PreparationTask } from "../store/store.js";
3
+ import { type ContextMessage, type ContextOptions, type PersonContext } from "./person-context.js";
4
+ import { type PersonMeetingContextItem } from "./person-meeting-context.js";
5
+ export declare const PREPARE_BYTES: number;
6
+ export interface WaitingDialog {
7
+ provider: Provider;
8
+ account: Id;
9
+ chatId: Id;
10
+ title: string | null;
11
+ /** Their message, the newest the store holds in this one-to-one chat. */
12
+ message: ContextMessage;
13
+ /** The store does not hold the whole chat, so a later answer may be missing from it. */
14
+ partial: boolean;
15
+ }
16
+ type Cut = "limit" | "bytes" | null;
17
+ interface SectionCoverage {
18
+ included: number;
19
+ hasMore: boolean;
20
+ cutBy: Cut;
21
+ }
22
+ export interface PersonPreparation extends PersonContext {
23
+ prepare: {
24
+ waiting: WaitingDialog[];
25
+ tasks: PreparationTask[];
26
+ facts: PreparationFact[];
27
+ notes: PreparationNote[];
28
+ meetings: PersonMeetingContextItem[];
29
+ limits: {
30
+ items: number;
31
+ bytes: number;
32
+ };
33
+ coverage: {
34
+ waiting: SectionCoverage & {
35
+ rule: "their message is the newest the store holds in a one-to-one chat";
36
+ };
37
+ tasks: SectionCoverage & {
38
+ selection: "open, assigned to them in any role";
39
+ accounts: string[];
40
+ };
41
+ facts: SectionCoverage & {
42
+ selection: "accepted, about them or a project they are a member of";
43
+ };
44
+ /** `withheld` when the profile denies `contacts.notes.list`. */
45
+ notes: SectionCoverage & {
46
+ selection: "written by you, about them";
47
+ withheld?: "contacts.notes.list";
48
+ };
49
+ /** `accounts`: the ones their meetings came from. */
50
+ meetings: SectionCoverage & {
51
+ read: "every account of the store that saw them";
52
+ accounts: string[];
53
+ pending: number;
54
+ };
55
+ };
56
+ };
57
+ }
58
+ /**
59
+ * `contacts context` and what to know before talking to them: the one-to-one chats where they wrote last, open tasks,
60
+ * accepted facts, your notes and their recent meetings, each item with its reference. The messages keep their own
61
+ * budget; the sections share what is left of `PREPARE_BYTES`, in that order, so evidence comes first.
62
+ */
63
+ export declare const personPreparation: (store: MessageStore, asked: AccountKey, reference: string, options?: ContextOptions & {
64
+ notes?: boolean;
65
+ }) => Promise<PersonPreparation>;
66
+ export {};
@@ -0,0 +1,126 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { parseMeetingReference } from "../meetings/index.js";
3
+ import { CONTEXT_BYTES, CONTEXT_MESSAGES, personContext, readsOf, toContext, } from "./person-context.js";
4
+ import { personMeetingContext } from "./person-meeting-context.js";
5
+ export const PREPARE_BYTES = 96 * 1024;
6
+ const MEETINGS = 5;
7
+ const CUES = 3;
8
+ const size = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
9
+ const accountName = ({ provider, account }) => `${provider}:${account}`;
10
+ /**
11
+ * `contacts context` and what to know before talking to them: the one-to-one chats where they wrote last, open tasks,
12
+ * accepted facts, your notes and their recent meetings, each item with its reference. The messages keep their own
13
+ * budget; the sections share what is left of `PREPARE_BYTES`, in that order, so evidence comes first.
14
+ */
15
+ export const personPreparation = async (store, asked, reference, options = {}) => {
16
+ const context = await personContext(store, asked, reference, { ...options, bytes: CONTEXT_BYTES });
17
+ const limit = options.messages ?? CONTEXT_MESSAGES;
18
+ let left = PREPARE_BYTES - size({ recent: context.recent, mentions: context.mentions });
19
+ const budgeted = (page) => {
20
+ const items = [];
21
+ let cut = false;
22
+ for (const item of page.items.slice(0, limit)) {
23
+ const bytes = size(item);
24
+ if (cut || bytes > left) {
25
+ cut = true;
26
+ continue;
27
+ }
28
+ left -= bytes;
29
+ items.push(item);
30
+ }
31
+ const over = page.hasMore || page.items.length > limit;
32
+ return {
33
+ items,
34
+ coverage: { included: items.length, hasMore: over || cut, cutBy: cut ? "bytes" : over ? "limit" : null },
35
+ };
36
+ };
37
+ const reads = readsOf(context.person, asked);
38
+ const partial = new Set(context.notRead.map((chat) => `${chat.provider}/${chat.account}/${chat.chatId}`));
39
+ const waiting = [];
40
+ for (const chat of context.shared.filter((one) => one.kind === "dialog")) {
41
+ const key = { provider: chat.provider, account: chat.account };
42
+ const [newest] = (await store.messages.messages(key, chat.chatId, { limit: 1 })).items;
43
+ if (newest?.outgoing !== false)
44
+ continue;
45
+ waiting.push({
46
+ ...key,
47
+ chatId: chat.chatId,
48
+ title: chat.title,
49
+ message: toContext(key, newest, chat.title),
50
+ partial: partial.has(`${chat.provider}/${chat.account}/${chat.chatId}`),
51
+ });
52
+ }
53
+ waiting.sort((a, b) => Date.parse(b.message.timestamp) - Date.parse(a.message.timestamp));
54
+ const accounts = [...new Map(reads.map(({ key }) => [accountName(key), key])).values()];
55
+ const held = await store.people.preparation(context.person.uid, { accounts, limit });
56
+ const stored = await store.accounts.storedAccounts();
57
+ const meetings = await meetingsOf(store, context.person.uid, stored, options.since, () => left);
58
+ const sections = {
59
+ waiting: budgeted({ items: waiting, hasMore: false }),
60
+ tasks: budgeted(held.tasks),
61
+ facts: budgeted(held.facts),
62
+ notes: budgeted(options.notes === false ? { items: [], hasMore: false } : held.notes),
63
+ meetings: budgeted({ items: meetings.items, hasMore: meetings.hasMore }),
64
+ };
65
+ return {
66
+ ...context,
67
+ prepare: {
68
+ waiting: sections.waiting.items,
69
+ tasks: sections.tasks.items,
70
+ facts: sections.facts.items,
71
+ notes: sections.notes.items,
72
+ meetings: sections.meetings.items,
73
+ limits: { items: limit, bytes: PREPARE_BYTES },
74
+ coverage: {
75
+ waiting: {
76
+ ...sections.waiting.coverage,
77
+ rule: "their message is the newest the store holds in a one-to-one chat",
78
+ },
79
+ tasks: {
80
+ ...sections.tasks.coverage,
81
+ selection: "open, assigned to them in any role",
82
+ accounts: accounts.map(accountName),
83
+ },
84
+ facts: { ...sections.facts.coverage, selection: "accepted, about them or a project they are a member of" },
85
+ notes: {
86
+ ...sections.notes.coverage,
87
+ selection: "written by you, about them",
88
+ ...(options.notes === false ? { withheld: "contacts.notes.list" } : {}),
89
+ },
90
+ meetings: {
91
+ ...sections.meetings.coverage,
92
+ read: "every account of the store that saw them",
93
+ accounts: meetings.accounts,
94
+ pending: meetings.pending,
95
+ },
96
+ },
97
+ },
98
+ };
99
+ };
100
+ /** Their recent meetings in every account of the store that saw them; none is not an error. */
101
+ const meetingsOf = async (store, uid, stored, since, left) => {
102
+ const none = { items: [], hasMore: false, accounts: [], pending: 0 };
103
+ if (!stored.length || left() < 2)
104
+ return none;
105
+ try {
106
+ const found = await personMeetingContext(store, `person:${uid}`, {
107
+ accountIds: stored.slice(0, 1000).map(({ id }) => id),
108
+ meetings: MEETINGS,
109
+ cues: CUES,
110
+ bytes: Math.min(left(), 4 * 1024 * 1024),
111
+ ...(since === undefined ? {} : { since }),
112
+ });
113
+ const read = new Set(found.items.map((item) => parseMeetingReference(item.reference).accountId));
114
+ return {
115
+ items: found.items,
116
+ hasMore: found.coverage.truncatedBy !== null || found.coverage.scanLimitReached,
117
+ accounts: stored.filter(({ id }) => read.has(id)).map(accountName),
118
+ pending: found.coverage.pending,
119
+ };
120
+ }
121
+ catch (error) {
122
+ if (error instanceof CliError && error.code === "not_found")
123
+ return none;
124
+ throw error;
125
+ }
126
+ };
@@ -31,6 +31,7 @@ export { OTHER_SOURCES, type OtherSource, type SourceMention } from "./sqlite/me
31
31
  export { MENTION_QUERY, MENTION_STATUSES, type MentionDecision, type MentionStatus, type MentionsRebuild, type MentionsState, type StoredMention, type StoredPlace, } from "./sqlite/mentions.js";
32
32
  export { drainNoteIndex, noteIndexState, resetNoteIndex } from "./sqlite/note-index.js";
33
33
  export type { Link as ThingLink } from "./sqlite/notes.js";
34
+ export type { PersonPreparationRead, PreparationFact, PreparationNote, PreparationTask, } from "./sqlite/person-preparation.js";
34
35
  export { savedPhoneCountries, saveStorePhoneCountries } from "./sqlite/phone-countries.js";
35
36
  export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people.js";
36
37
  export type { ProposedAction } from "./sqlite/proposed-actions.js";
@@ -2,6 +2,7 @@ import type { Contact, Id, Page, PersonAlias, Provider } from "../domain/models.
2
2
  import type { PeopleLookup } from "../resolve.js";
3
3
  import type { ContactFilter } from "./sqlite/identities.js";
4
4
  import type { StoreContext } from "./sqlite/open.js";
5
+ import { type PersonPreparationRead } from "./sqlite/person-preparation.js";
5
6
  import type { PrivateContact, PrivateContactNote } from "./sqlite/private-people.js";
6
7
  import type { AccountKey, IdentityRef, LinkOptions, PersonFacts, PersonRecord } from "./types.js";
7
8
  export interface AccountPeople {
@@ -44,6 +45,11 @@ export interface PeopleStore extends AccountPeople {
44
45
  /** The person a `person:<uid>` reference names. */
45
46
  personSeenInAccounts(uid: string, accountIds: readonly number[]): Promise<boolean>;
46
47
  personByUid(uid: string): Promise<PersonRecord | undefined>;
48
+ /** Accepted facts, the owner's notes and open tasks about them; tasks of an account only if `accounts` holds it. */
49
+ preparation(uid: string, options: {
50
+ accounts: readonly AccountKey[];
51
+ limit: number;
52
+ }): Promise<PersonPreparationRead>;
47
53
  /** Records the decision in `identity_link_events`; `unlinkIdentity` undoes it. */
48
54
  linkIdentities(person: IdentityRef, other: IdentityRef, options: LinkOptions): Promise<PersonRecord>;
49
55
  unlinkIdentity(identity: IdentityRef, options: LinkOptions): Promise<PersonRecord>;
@@ -2,6 +2,7 @@ import { keysOver } from "./keys.js";
2
2
  import * as identities from "./sqlite/identities.js";
3
3
  import { linkToOwner } from "./sqlite/own-identities.js";
4
4
  import * as personLinks from "./sqlite/person-links.js";
5
+ import { personPreparation } from "./sqlite/person-preparation.js";
5
6
  import { personSeenInAccounts } from "./sqlite/person-scope.js";
6
7
  import * as privatePeople from "./sqlite/private-people.js";
7
8
  import { write } from "./sqlite/transaction.js";
@@ -55,6 +56,7 @@ export const peopleStoreOver = (context, keyed) => {
55
56
  personOf: async (identity) => personLinks.personOf(context, identity),
56
57
  personSeenInAccounts: async (uid, accountIds) => personSeenInAccounts(context, uid, accountIds),
57
58
  personByUid: async (uid) => personLinks.personByUid(context, uid),
59
+ preparation: async (uid, options) => personPreparation(context, uid, options),
58
60
  linkIdentities: async (person, other, options) => {
59
61
  return write(database, () => personLinks.linkIdentities(context, person, other, options));
60
62
  },
@@ -0,0 +1,48 @@
1
+ import type { AccountKey } from "../types.js";
2
+ import type { StoreContext } from "./open.js";
3
+ export interface PreparationFact {
4
+ ref: string;
5
+ type: string;
6
+ statement: string;
7
+ subject: string | null;
8
+ project: string | null;
9
+ updatedAt: string;
10
+ }
11
+ export interface PreparationNote {
12
+ ref: string;
13
+ title: string | null;
14
+ text: string;
15
+ revision: number;
16
+ updatedAt: string;
17
+ }
18
+ export interface PreparationTask {
19
+ ref: string;
20
+ title: string;
21
+ role: string;
22
+ dueAt: string | null;
23
+ project: string;
24
+ source: string | null;
25
+ createdAt: string;
26
+ }
27
+ export interface PersonPreparationRead {
28
+ facts: {
29
+ items: PreparationFact[];
30
+ hasMore: boolean;
31
+ };
32
+ notes: {
33
+ items: PreparationNote[];
34
+ hasMore: boolean;
35
+ };
36
+ tasks: {
37
+ items: PreparationTask[];
38
+ hasMore: boolean;
39
+ };
40
+ }
41
+ /**
42
+ * The accepted facts, the owner's notes and the open tasks about one person. A project bound to an account is that
43
+ * account's: its facts never come along, and its tasks only when `accounts` holds it.
44
+ */
45
+ export declare const personPreparation: ({ database }: Pick<StoreContext, "database">, uid: string, { accounts, limit }: {
46
+ accounts: readonly AccountKey[];
47
+ limit: number;
48
+ }) => PersonPreparationRead;
@@ -0,0 +1,72 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { linkKind } from "./link-kinds.js";
3
+ import { taskIdOf } from "./things.js";
4
+ import { read } from "./transaction.js";
5
+ const GLOBAL_PROJECT = "SELECT id FROM main.projects WHERE account_id IS NULL AND deleted_at IS NULL";
6
+ const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
7
+ /**
8
+ * The accepted facts, the owner's notes and the open tasks about one person. A project bound to an account is that
9
+ * account's: its facts never come along, and its tasks only when `accounts` holds it.
10
+ */
11
+ export const personPreparation = ({ database }, uid, { accounts, limit }) => {
12
+ const person = Number(uid);
13
+ if (!/^[1-9]\d*$/.test(uid) || !Number.isSafeInteger(person) || !Number.isSafeInteger(limit) || limit < 1)
14
+ throw new CliError("validation_error", "person preparation takes a person id and a positive limit");
15
+ const page = (rows) => ({ items: rows.slice(0, limit), hasMore: rows.length > limit });
16
+ return read(database, () => {
17
+ const projects = `SELECT l.to_id FROM links l WHERE l.from_type='person' AND l.from_id=? AND l.to_type='project'
18
+ AND l.kind=${linkKind("member-of")} AND l.confirmed=1 AND l.to_id IN (${GLOBAL_PROJECT})`;
19
+ const facts = database
20
+ .prepare(`SELECT f.id,f.type,f.statement,f.subject_type,f.subject_id,f.project_id,f.updated_at FROM facts f
21
+ WHERE f.status='accepted' AND f.deleted_at IS NULL
22
+ AND ((f.subject_type='person' AND f.subject_id=?) OR (f.subject_type='project' AND f.subject_id IN (${projects}))
23
+ OR f.project_id IN (${projects}))
24
+ AND (f.subject_type IS NOT 'project' OR f.subject_id IN (${GLOBAL_PROJECT}))
25
+ AND (f.project_id IS NULL OR f.project_id IN (${GLOBAL_PROJECT}))
26
+ ORDER BY f.updated_at DESC, f.id DESC LIMIT ?`)
27
+ .all(person, person, person, limit + 1)
28
+ .map((row) => ({
29
+ ref: `fact:${row.id}`,
30
+ type: String(row.type),
31
+ statement: String(row.statement),
32
+ subject: row.subject_type == null ? null : `${row.subject_type}:${row.subject_id}`,
33
+ project: row.project_id == null ? null : `project:${row.project_id}`,
34
+ updatedAt: iso(row.updated_at),
35
+ }));
36
+ const notes = database
37
+ .prepare(`SELECT n.id,n.title,n.body,n.revision,n.updated_at FROM notes n
38
+ JOIN persons owner ON n.author_type='person' AND n.author_id=owner.id AND owner.owner=1
39
+ WHERE n.deleted_at IS NULL
40
+ AND ((n.notable_type='person' AND n.notable_id=?) OR EXISTS(SELECT 1 FROM links l WHERE l.from_type='note'
41
+ AND l.from_id=n.id AND l.to_type='person' AND l.to_id=? AND l.kind=${linkKind("about")} AND l.confirmed=1))
42
+ AND (n.notable_type='person' OR (n.notable_type='project' AND n.notable_id IN (${GLOBAL_PROJECT})))
43
+ ORDER BY n.updated_at DESC, n.id DESC LIMIT ?`)
44
+ .all(person, person, limit + 1)
45
+ .map((row) => ({
46
+ ref: `note:${row.id}`,
47
+ title: row.title == null ? null : String(row.title),
48
+ text: String(row.body),
49
+ revision: Number(row.revision),
50
+ updatedAt: iso(row.updated_at),
51
+ }));
52
+ const tasks = database
53
+ .prepare(`SELECT t.id,t.title,t.due_at,t.project_id,t.source_locator,t.created_at,group_concat(DISTINCT ta.role) AS role
54
+ FROM task_assignments ta JOIN tasks t ON t.id=ta.task_id JOIN main.projects p ON p.id=t.project_id
55
+ WHERE ta.assignee_type='person' AND ta.assignee_id=? AND t.deleted_at IS NULL AND t.status='open'
56
+ AND p.deleted_at IS NULL AND (p.account_id IS NULL OR p.account_id IN (SELECT a.id FROM accounts a
57
+ JOIN json_each(?) k ON a.provider=json_extract(k.value,'$.provider')
58
+ AND a.external_id=json_extract(k.value,'$.account')))
59
+ GROUP BY t.id ORDER BY t.due_at IS NULL, t.due_at, t.created_at, t.id LIMIT ?`)
60
+ .all(person, JSON.stringify(accounts.map(({ provider, account }) => ({ provider, account }))), limit + 1)
61
+ .map((row) => ({
62
+ ref: `task:${taskIdOf(database, Number(row.id))}`,
63
+ title: String(row.title),
64
+ role: String(row.role),
65
+ dueAt: iso(row.due_at),
66
+ project: `project:${row.project_id}`,
67
+ source: row.source_locator == null ? null : String(row.source_locator),
68
+ createdAt: iso(row.created_at),
69
+ }));
70
+ return { facts: page(facts), notes: page(notes), tasks: page(tasks) };
71
+ });
72
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.393.0",
3
+ "version": "0.394.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",
package/parity.json CHANGED
@@ -2816,6 +2816,13 @@
2816
2816
  "contacts notes show": {
2817
2817
  "in": "all"
2818
2818
  },
2819
+ "contacts prepare": {
2820
+ "in": "all",
2821
+ "options": {
2822
+ "--limit": "all",
2823
+ "--since-time": "all"
2824
+ }
2825
+ },
2819
2826
  "contacts profile": {
2820
2827
  "in": "all",
2821
2828
  "options": {