@wirecat/cli-core 0.393.0 → 0.395.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 (57) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli/config-command.js +5 -2
  3. package/dist/cli/messenger/account-command.js +8 -3
  4. package/dist/cli/messenger/account-sessions-command.js +7 -2
  5. package/dist/cli/messenger/archive-commands.js +28 -0
  6. package/dist/cli/messenger/contacts-command.js +29 -0
  7. package/dist/cli/messenger/conversation-examples.d.ts +2 -0
  8. package/dist/cli/messenger/conversation-examples.js +25 -0
  9. package/dist/cli/messenger/conversations-command.js +2 -0
  10. package/dist/cli/messenger/mcp-command.js +12 -3
  11. package/dist/cli/messenger/models-command.js +15 -6
  12. package/dist/cli/messenger/polls-command.js +20 -5
  13. package/dist/cli/messenger/records-command.js +10 -2
  14. package/dist/cli/messenger/search-command.js +12 -0
  15. package/dist/cli/messenger/server-command.js +19 -7
  16. package/dist/cli/messenger/stats-command.js +26 -0
  17. package/dist/cli/messenger/tasks-command.js +18 -6
  18. package/dist/cli/runs/command.js +11 -4
  19. package/dist/cli/skill-command.js +6 -1
  20. package/dist/entities/index.d.ts +1 -1
  21. package/dist/entities/saved-search.d.ts +1 -1
  22. package/dist/entities/saved-search.js +1 -1
  23. package/dist/errors/reasons.js +1 -0
  24. package/dist/meetings/cli/search.js +1 -1
  25. package/dist/meetings/hit-query.d.ts +10 -0
  26. package/dist/meetings/hit-query.js +85 -1
  27. package/dist/meetings/index.d.ts +1 -1
  28. package/dist/meetings/index.js +1 -1
  29. package/dist/sends/permissions.js +2 -1
  30. package/dist/services/index.d.ts +5 -2
  31. package/dist/services/index.js +3 -1
  32. package/dist/services/meeting-combined.d.ts +1 -1
  33. package/dist/services/meeting-combined.js +4 -3
  34. package/dist/services/meeting-hit-search.d.ts +1 -1
  35. package/dist/services/meeting-hit-search.js +4 -2
  36. package/dist/services/meeting-searches.d.ts +25 -0
  37. package/dist/services/meeting-searches.js +35 -0
  38. package/dist/services/people.d.ts +5 -0
  39. package/dist/services/people.js +2 -0
  40. package/dist/services/person-context.d.ts +12 -2
  41. package/dist/services/person-context.js +2 -2
  42. package/dist/services/person-preparation.d.ts +66 -0
  43. package/dist/services/person-preparation.js +126 -0
  44. package/dist/services/search-record.d.ts +1 -1
  45. package/dist/services/searches.d.ts +1 -0
  46. package/dist/services/searches.js +17 -4
  47. package/dist/store/exports.d.ts +1 -0
  48. package/dist/store/people.d.ts +6 -0
  49. package/dist/store/people.js +2 -0
  50. package/dist/store/searches.d.ts +5 -4
  51. package/dist/store/searches.js +4 -4
  52. package/dist/store/sqlite/person-preparation.d.ts +48 -0
  53. package/dist/store/sqlite/person-preparation.js +72 -0
  54. package/dist/store/sqlite/searches.d.ts +4 -4
  55. package/dist/store/sqlite/searches.js +21 -6
  56. package/package.json +1 -1
  57. package/parity.json +7 -0
@@ -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
+ };
@@ -21,4 +21,4 @@ export interface SearchParams extends RankingInput {
21
21
  by?: StatsGrouping;
22
22
  }
23
23
  /** One run or one saved search as the store keeps it; the same parameters always give the same text. */
24
- export declare const searchRecordOf: (command: SearchCommand, params: SearchParams) => SearchRecord;
24
+ export declare const searchRecordOf: (command: SearchCommand, params: SearchParams | Readonly<Record<string, unknown>>) => SearchRecord;
@@ -33,4 +33,5 @@ export interface SearchesService {
33
33
  */
34
34
  resolve(reference: string, typed: SearchParams): Promise<ResolvedSearch>;
35
35
  }
36
+ export declare const nameOf: (value: string) => string;
36
37
  export declare const searchesService: (deps: ServiceDeps) => SearchesService;
@@ -17,8 +17,16 @@ export const savedSearchItem = (one) => ({
17
17
  last_run_at: one.lastRunAt,
18
18
  runs: one.runs,
19
19
  });
20
+ /** What the messenger commands run; the table is shared, and another CLI's rows are not theirs to list or run. */
21
+ const MESSAGE_SEARCH_COMMANDS = [
22
+ "search",
23
+ "stats",
24
+ "message-top",
25
+ "author-top",
26
+ "admin-statistics",
27
+ ];
20
28
  const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
21
- const nameOf = (value) => {
29
+ export const nameOf = (value) => {
22
30
  const name = value.trim().toLowerCase();
23
31
  if (!NAME.test(name) || /^\d+$/.test(name))
24
32
  throw new CliError("validation_error", `"${singleLine(value)}" cannot name a saved search — use up to 64 letters a–z, digits and hyphens, not only digits`, { reason: "name.invalid" });
@@ -63,6 +71,11 @@ export const searchesService = (deps) => {
63
71
  const search = await store.searches.storedSearch(reference.trim().toLowerCase());
64
72
  if (!search)
65
73
  throw new CliError("not_found", `no saved search or run "${singleLine(reference)}"`);
74
+ if (!MESSAGE_SEARCH_COMMANDS.includes(search.command))
75
+ throw new CliError("validation_error", `"${singleLine(reference)}" is a saved meeting search, not one of messages`, {
76
+ reason: "search.kind",
77
+ command: search.command,
78
+ });
66
79
  return search;
67
80
  };
68
81
  return {
@@ -87,13 +100,13 @@ export const searchesService = (deps) => {
87
100
  });
88
101
  }),
89
102
  show: (reference) => inStore((store) => found(store, reference)),
90
- list: () => inStore((store) => store.searches.savedSearches()),
103
+ list: () => inStore((store) => store.searches.savedSearches(MESSAGE_SEARCH_COMMANDS)),
91
104
  history: (limit, offset = 0) => inStore(async (store) => {
92
- const rows = await store.searches.searchHistory(limit, offset);
105
+ const rows = await store.searches.searchHistory(limit, offset, MESSAGE_SEARCH_COMMANDS);
93
106
  return { items: rows.slice(0, limit), has_more: rows.length > limit };
94
107
  }),
95
108
  delete: (reference) => inStore(async (store) => store.searches.deleteSearch((await found(store, reference)).id)),
96
- clear: () => inStore(async (store) => ({ cleared: await store.searches.clearSearchHistory() })),
109
+ clear: () => inStore(async (store) => ({ cleared: await store.searches.clearSearchHistory(MESSAGE_SEARCH_COMMANDS) })),
97
110
  resolve: (reference, { text: words, ...typed }) => inStore(async (store) => {
98
111
  const search = await found(store, reference);
99
112
  const stored = search.params;
@@ -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
  },
@@ -1,5 +1,5 @@
1
1
  import type { StoreContext } from "./sqlite/open.js";
2
- import type { SearchRecord, StoredSearch } from "./sqlite/searches.js";
2
+ import type { SearchCommand, SearchRecord, StoredSearch } from "./sqlite/searches.js";
3
3
  export interface SearchesStore {
4
4
  /** One run of a search or stats; the same unnamed run again counts on its row. `saved` is the saved search it ran. */
5
5
  recordSearch(run: SearchRecord, options?: {
@@ -11,11 +11,12 @@ export interface SearchesStore {
11
11
  }): Promise<StoredSearch>;
12
12
  /** A saved search by name, or any row by id. */
13
13
  storedSearch(reference: string): Promise<StoredSearch | undefined>;
14
- savedSearches(): Promise<StoredSearch[]>;
14
+ /** `commands`: only rows of these kinds; the table is shared by every CLI on the store. */
15
+ savedSearches(commands?: readonly SearchCommand[]): Promise<StoredSearch[]>;
15
16
  /** Runs, newest first, one more than `limit` when there are more. */
16
- searchHistory(limit: number, offset?: number): Promise<StoredSearch[]>;
17
+ searchHistory(limit: number, offset?: number, commands?: readonly SearchCommand[]): Promise<StoredSearch[]>;
17
18
  deleteSearch(reference: string): Promise<StoredSearch>;
18
19
  /** Drops the unnamed runs; answers how many. */
19
- clearSearchHistory(): Promise<number>;
20
+ clearSearchHistory(commands?: readonly SearchCommand[]): Promise<number>;
20
21
  }
21
22
  export declare const searchesStoreOver: (context: StoreContext) => SearchesStore;
@@ -8,15 +8,15 @@ export const searchesStoreOver = (context) => {
8
8
  return write(database, () => searchRecords.saveSearch(context, name, search, replace));
9
9
  },
10
10
  storedSearch: async (reference) => searchRecords.findSearch(context, reference),
11
- savedSearches: async () => searchRecords.savedSearches(context),
12
- searchHistory: async (limit, offset) => searchRecords.searchHistory(context, limit, offset),
11
+ savedSearches: async (commands) => searchRecords.savedSearches(context, commands),
12
+ searchHistory: async (limit, offset, commands) => searchRecords.searchHistory(context, limit, offset, commands),
13
13
  deleteSearch: async (reference) => {
14
14
  return write(database, () => searchRecords.deleteSearch(context, reference));
15
15
  },
16
- clearSearchHistory: async () => {
16
+ clearSearchHistory: async (commands) => {
17
17
  let cleared = 0;
18
18
  write(database, () => {
19
- cleared = searchRecords.clearHistory(context);
19
+ cleared = searchRecords.clearHistory(context, commands);
20
20
  });
21
21
  return cleared;
22
22
  },
@@ -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
+ };
@@ -1,7 +1,7 @@
1
1
  import type { StoreContext } from "./open.js";
2
2
  /** Unnamed runs kept, newest first; a saved search is never pruned. */
3
3
  export declare const HISTORY_KEPT = 1000;
4
- export type SearchCommand = "search" | "stats" | "message-top" | "author-top" | "admin-statistics";
4
+ export type SearchCommand = "search" | "stats" | "message-top" | "author-top" | "admin-statistics" | "meetings-search";
5
5
  /** What one run or one saved search is: `params` as canonical JSON, so the same run is the same text. */
6
6
  export interface SearchRecord {
7
7
  command: SearchCommand;
@@ -26,9 +26,9 @@ export declare const recordRun: ({ database, now }: StoreContext, run: SearchRec
26
26
  export declare const findSearch: ({ database }: StoreContext, reference: string) => StoredSearch | undefined;
27
27
  /** A name already taken is refused unless `replace`, which starts it afresh: new parameters, no runs. */
28
28
  export declare const saveSearch: (context: StoreContext, name: string, search: SearchRecord, replace: boolean) => StoredSearch;
29
- export declare const savedSearches: ({ database }: StoreContext) => StoredSearch[];
29
+ export declare const savedSearches: ({ database }: StoreContext, commands?: readonly SearchCommand[]) => StoredSearch[];
30
30
  /** Runs, newest first: unnamed ones and saved searches that have run. One more than `limit` says there are more. */
31
- export declare const searchHistory: ({ database }: StoreContext, limit: number, offset?: number) => StoredSearch[];
31
+ export declare const searchHistory: ({ database }: StoreContext, limit: number, offset?: number, commands?: readonly SearchCommand[]) => StoredSearch[];
32
32
  export declare const deleteSearch: (context: StoreContext, reference: string) => StoredSearch;
33
33
  /** Unnamed runs only; saved searches stay. */
34
- export declare const clearHistory: ({ database }: StoreContext) => number;
34
+ export declare const clearHistory: ({ database }: StoreContext, commands?: readonly SearchCommand[]) => number;
@@ -2,6 +2,9 @@ import { CliError, singleLine } from "@wirecat/cli-base";
2
2
  import { toIso } from "./values.js";
3
3
  /** Unnamed runs kept, newest first; a saved search is never pruned. */
4
4
  export const HISTORY_KEPT = 1000;
5
+ const commandFilter = (commands) => commands
6
+ ? { sql: ` AND command IN (${commands.map(() => "?").join(", ")})`, values: [...commands] }
7
+ : { sql: "", values: [] };
5
8
  const COLUMNS = "id, name, command, params, language, version, fields_version, created_at, updated_at, last_run_at, runs";
6
9
  const searchOf = (row) => ({
7
10
  id: String(row.id),
@@ -61,16 +64,28 @@ export const saveSearch = (context, name, search, replace) => {
61
64
  .run(...values, now(), name);
62
65
  return mustFind(context, name);
63
66
  };
64
- export const savedSearches = ({ database }) => database.prepare(`SELECT ${COLUMNS} FROM searches WHERE name IS NOT NULL ORDER BY name`).all().map(searchOf);
67
+ export const savedSearches = ({ database }, commands) => {
68
+ const only = commandFilter(commands);
69
+ return database
70
+ .prepare(`SELECT ${COLUMNS} FROM searches WHERE name IS NOT NULL${only.sql} ORDER BY name`)
71
+ .all(...only.values)
72
+ .map(searchOf);
73
+ };
65
74
  /** Runs, newest first: unnamed ones and saved searches that have run. One more than `limit` says there are more. */
66
- export const searchHistory = ({ database }, limit, offset = 0) => database
67
- .prepare(`SELECT ${COLUMNS} FROM searches WHERE last_run_at IS NOT NULL ORDER BY last_run_at DESC, id DESC LIMIT ? OFFSET ?`)
68
- .all(limit + 1, offset)
69
- .map(searchOf);
75
+ export const searchHistory = ({ database }, limit, offset = 0, commands) => {
76
+ const only = commandFilter(commands);
77
+ return database
78
+ .prepare(`SELECT ${COLUMNS} FROM searches WHERE last_run_at IS NOT NULL${only.sql} ORDER BY last_run_at DESC, id DESC LIMIT ? OFFSET ?`)
79
+ .all(...only.values, limit + 1, offset)
80
+ .map(searchOf);
81
+ };
70
82
  export const deleteSearch = (context, reference) => {
71
83
  const found = mustFind(context, reference);
72
84
  context.database.prepare("DELETE FROM searches WHERE id = ?").run(Number(found.id));
73
85
  return found;
74
86
  };
75
87
  /** Unnamed runs only; saved searches stay. */
76
- export const clearHistory = ({ database }) => database.prepare("DELETE FROM searches WHERE name IS NULL").run().changes;
88
+ export const clearHistory = ({ database }, commands) => {
89
+ const only = commandFilter(commands);
90
+ return database.prepare(`DELETE FROM searches WHERE name IS NULL${only.sql}`).run(...only.values).changes;
91
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.393.0",
3
+ "version": "0.395.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": {