@wirecat/cli-core 0.491.0 → 0.493.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 (37) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/cli/messenger/conversation-answers.d.ts +7 -0
  3. package/dist/cli/messenger/message-answers.d.ts +7 -0
  4. package/dist/cli/messenger/message-writes.d.ts +14 -0
  5. package/dist/cli/messenger/messages-command.js +13 -1
  6. package/dist/cli/messenger/messages-search-command.js +4 -1
  7. package/dist/cli/messenger/search-answers.d.ts +6 -2
  8. package/dist/cli/messenger/search-answers.js +60 -7
  9. package/dist/cli/messenger/tasks-command.js +15 -3
  10. package/dist/cli/steps.js +1 -0
  11. package/dist/entities/agenda.d.ts +42 -0
  12. package/dist/entities/agenda.js +10 -0
  13. package/dist/entities/conversation-results.d.ts +14 -0
  14. package/dist/entities/evidence.d.ts +56 -0
  15. package/dist/entities/index.d.ts +169 -0
  16. package/dist/entities/index.js +1 -0
  17. package/dist/entities/listener.d.ts +28 -0
  18. package/dist/entities/message-context.d.ts +21 -0
  19. package/dist/entities/message-write.d.ts +28 -0
  20. package/dist/entities/message.d.ts +11 -0
  21. package/dist/entities/message.js +11 -0
  22. package/dist/entities/store-records.d.ts +7 -0
  23. package/dist/services/agenda.d.ts +5 -0
  24. package/dist/services/agenda.js +25 -4
  25. package/dist/services/chat-coverage.d.ts +19 -0
  26. package/dist/services/chat-coverage.js +27 -0
  27. package/dist/services/messages-discovery.d.ts +6 -0
  28. package/dist/services/messages-discovery.js +50 -3
  29. package/dist/services/messages-search.d.ts +2 -0
  30. package/dist/services/messages.d.ts +2 -0
  31. package/dist/services/search-all.d.ts +3 -0
  32. package/dist/services/search-all.js +4 -0
  33. package/dist/store/messages.d.ts +9 -0
  34. package/dist/store/messages.js +2 -0
  35. package/dist/store/sqlite/follow-ups.d.ts +17 -0
  36. package/dist/store/sqlite/follow-ups.js +59 -0
  37. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { formatLocator, isLocator, parseLocator } from "../domain/locator.js";
3
3
  import { formatReference } from "../domain/references.js";
4
4
  import { agenda } from "../entities/agenda.js";
5
5
  import { AGENDA_COLUMNS, inboxKey, queryFilter, } from "../store/store.js";
6
+ import { chatCoverage } from "./chat-coverage.js";
6
7
  import { CHAT_KINDS } from "./chats.js";
7
8
  import { taskAccount } from "./task-rules.js";
8
9
  import { taskTagFilters } from "./task-tag-filters.js";
@@ -102,21 +103,29 @@ export const agendaService = (deps, now = Date.now) => ({
102
103
  }),
103
104
  openTasks: open.filter((task) => chatOf(task.source) === chat.chatId).map((task) => task.id),
104
105
  }));
106
+ const silentFor = (chat) => at - Date.parse(chat.lastMessageAt);
107
+ const shown = {
108
+ owed: chats.filter((chat) => chat.lastFromOwner === false && silentFor(chat) >= unanswered),
109
+ waiting: chats.filter((chat) => chat.lastFromOwner === true && silentFor(chat) >= waiting),
110
+ unsynced: chats.filter((chat) => chat.lastMessage === null),
111
+ };
112
+ const covered = new Map((await chatCoverage(store, account, Object.values(shown).flatMap((list) => list.map(({ chatId }) => chatId)))).map((one) => [one.chatId, one]));
113
+ const withCoverage = (list) => list.map((chat) => ({ ...chat, coverage: covered.get(chat.chatId) }));
105
114
  const inList = new Set((await store.tasks.categories()).map(({ name }) => name));
106
115
  const unlisted = new Map();
107
116
  for (const { category } of open)
108
117
  if (category && !inList.has(category))
109
118
  unlisted.set(category, (unlisted.get(category) ?? 0) + 1);
110
- const silentFor = (chat) => at - Date.parse(chat.lastMessageAt);
111
119
  return {
112
120
  at: new Date(at).toISOString(),
121
+ since: new Date(at - within).toISOString(),
113
122
  account: taskAccount(account),
114
123
  storedAt: activity.storedAt,
115
124
  stale: activity.storedAt === null || at - Date.parse(activity.storedAt) > DAY,
116
125
  tasks,
117
- owed: chats.filter((chat) => chat.lastFromOwner === false && silentFor(chat) >= unanswered),
118
- waiting: chats.filter((chat) => chat.lastFromOwner === true && silentFor(chat) >= waiting),
119
- unsynced: chats.filter((chat) => chat.lastMessage === null),
126
+ owed: withCoverage(shown.owed),
127
+ waiting: withCoverage(shown.waiting),
128
+ unsynced: withCoverage(shown.unsynced),
120
129
  assigned,
121
130
  unlistedCategories: [...unlisted].sort().map(([name, openTasks]) => ({ name, openTasks })),
122
131
  };
@@ -208,6 +217,18 @@ export const agendaItem = (shown) => {
208
217
  last_message_at: one.lastMessageAt,
209
218
  last_from_owner: one.lastFromOwner,
210
219
  open_tasks: one.openTasks,
220
+ completeness: {
221
+ state: one.coverage.completeness.state,
222
+ up_to_date: one.coverage.completeness.upToDate,
223
+ gaps: one.coverage.completeness.gaps,
224
+ reaches_start: one.coverage.completeness.reachesStart,
225
+ fetched_at: one.coverage.completeness.fetchedAt,
226
+ },
227
+ held: {
228
+ messages: one.coverage.held.messages,
229
+ oldest_at: one.coverage.held.oldestAt,
230
+ newest_at: one.coverage.held.newestAt,
231
+ },
211
232
  });
212
233
  return {
213
234
  at: shown.at,
@@ -0,0 +1,19 @@
1
+ import type { Id } from "../domain/models.js";
2
+ import type { AccountKey, ChatCompleteness, MessageStore } from "../store/store.js";
3
+ /** What the store holds of one chat, and how far that goes: never message text. */
4
+ export interface ChatCoverage {
5
+ chatId: Id;
6
+ /** `null` while the store holds no message of the chat: titles come with the stats. */
7
+ title: string | null;
8
+ completeness: ChatCompleteness;
9
+ held: {
10
+ messages: number;
11
+ oldestAt: string | null;
12
+ newestAt: string | null;
13
+ };
14
+ }
15
+ /**
16
+ * Per chat, in the order asked: `chatCompleteness` with the span `chatStats` holds. Stats are read one chat at a time,
17
+ * because the account-wide read groups every message of every chat. A chat the store has no row for is `unknown`.
18
+ */
19
+ export declare const chatCoverage: (store: Pick<MessageStore, "chats">, account: AccountKey, chatIds: Id[]) => Promise<ChatCoverage[]>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Per chat, in the order asked: `chatCompleteness` with the span `chatStats` holds. Stats are read one chat at a time,
3
+ * because the account-wide read groups every message of every chat. A chat the store has no row for is `unknown`.
4
+ */
5
+ export const chatCoverage = async (store, account, chatIds) => {
6
+ const ids = [...new Set(chatIds)];
7
+ if (ids.length === 0)
8
+ return [];
9
+ const found = new Map((await store.chats.chatCompleteness(account, ids)).map((one) => [one.chatId, one]));
10
+ return Promise.all(ids.map(async (chatId) => {
11
+ const [stats] = await store.chats.chatStats(account, chatId);
12
+ const completeness = found.get(chatId) ?? {
13
+ chatId,
14
+ state: "unknown",
15
+ upToDate: null,
16
+ gaps: false,
17
+ reachesStart: false,
18
+ fetchedAt: null,
19
+ };
20
+ return {
21
+ chatId,
22
+ title: stats?.title ?? null,
23
+ completeness,
24
+ held: { messages: stats?.messages ?? 0, oldestAt: stats?.oldestAt ?? null, newestAt: stats?.newestAt ?? null },
25
+ };
26
+ }));
27
+ };
@@ -12,6 +12,12 @@ export declare const RERANK_DEPTH = 50;
12
12
  * ~7–10 s of it (cli-testing performance/search/evidence-pipeline, "In Core").
13
13
  */
14
14
  export declare const RERANK_MILLISECONDS = 15000;
15
+ /**
16
+ * Follow-ups: each of the first 5 hits gets up to 2 later messages that share a word at most 3 messages hold. A
17
+ * blind benchmark split found 4 more of 15 cancelled decisions this way and lost no answer (cli-testing
18
+ * performance/search/evidence-pipeline, "Cancelled decisions and long messages").
19
+ */
20
+ export declare const FOLLOW_UP_HITS = 5;
15
21
  /** The reranker, opened; or why discovery keeps its own order. */
16
22
  export type RerankLane = () => Promise<{
17
23
  model: string;
@@ -18,11 +18,23 @@ const RERANK_CHUNK = 5;
18
18
  * ~7–10 s of it (cli-testing performance/search/evidence-pipeline, "In Core").
19
19
  */
20
20
  export const RERANK_MILLISECONDS = 15_000;
21
+ /**
22
+ * Follow-ups: each of the first 5 hits gets up to 2 later messages that share a word at most 3 messages hold. A
23
+ * blind benchmark split found 4 more of 15 cancelled decisions this way and lost no answer (cli-testing
24
+ * performance/search/evidence-pipeline, "Cancelled decisions and long messages").
25
+ */
26
+ export const FOLLOW_UP_HITS = 5;
27
+ const FOLLOW_UPS_PER_HIT = 2;
28
+ const RARE_AT = 3;
29
+ /** Later messages read per hit before the search's filters; the latest that pass are kept. */
30
+ const FOLLOW_UP_CANDIDATES = 20;
21
31
  export const searchDiscovery = async (store, account, asked, messenger = {}, meaning, rerank) => {
22
- // A question's own "?" would parse as a one-character wildcard and send it to strict search (BUG-395).
32
+ // A question's own "?" would parse as a one-character wildcard and send it to strict search (BUG-395); the
33
+ // colon of a time or ratio ("22:00", "1:1") would parse as a field and fail the whole search, and escaped it is
34
+ // syntax again, so it splits the numbers (BUG-450).
23
35
  const request = asked.ast !== undefined || asked.exact || !asked.text
24
36
  ? asked
25
- : { ...asked, text: asked.text.replace(/(?<=\p{L})\?(?=\s|$)/gu, "") };
37
+ : { ...asked, text: asked.text.replace(/(?<=\p{L})\?(?=\s|$)/gu, "").replace(/(?<=\d):(?=\d)/gu, " ") };
26
38
  const original = await prepareLucene(store, account, request, messenger);
27
39
  const text = request.text ?? "";
28
40
  const plan = planQuestion(text, request.ast !== undefined || !!request.exact || !!request.newest);
@@ -208,7 +220,10 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
208
220
  started += performance.now() - opening;
209
221
  }
210
222
  check();
211
- const items = await Promise.all(candidates.slice(0, request.limit).map(async ({ fused: _fused, ...m }) => {
223
+ const followUps = await followUpsOf(store, account, request, messenger, candidates, plan.scope);
224
+ const items = await Promise.all(candidates.slice(0, request.limit).map(async ({ fused: _fused, ...hit }) => {
225
+ const later = followUps.get(hit.locator);
226
+ const m = later?.length ? { ...hit, followUps: later } : hit;
212
227
  if (!request.context)
213
228
  return m;
214
229
  check();
@@ -235,6 +250,7 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
235
250
  candidateDepth: 300,
236
251
  candidates: candidates.length,
237
252
  repliesAdded: replyCount,
253
+ followUps: [...followUps.values()].reduce((sum, later) => sum + later.length, 0),
238
254
  truncated,
239
255
  terms,
240
256
  queries: [...(plan.changed ? [] : plan.queries), relaxed],
@@ -247,6 +263,37 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
247
263
  ...(lane && "partly" in lane && lane.partly ? { meaningSkipped: lane.partly } : {}),
248
264
  };
249
265
  };
266
+ /**
267
+ * The follow-ups of the first hits that the question's filters allow, by hit. A message already shown above — a hit
268
+ * or an earlier hit's follow-up — is not shown again, as the benchmark skipped it.
269
+ */
270
+ const followUpsOf = async (store, account, request, messenger, candidates, scope) => {
271
+ const head = candidates.slice(0, Math.min(FOLLOW_UP_HITS, request.limit));
272
+ const later = await store.messages.laterSharingRare(head.map((hit) => ({ account: accountOfHit(hit), chatId: hit.chatId, id: hit.id })), { rareAt: RARE_AT, limit: FOLLOW_UP_CANDIDATES });
273
+ const only = [...new Map(later.flat().map((m) => [m.locator, m])).values()].map((m) => ({
274
+ ...accountOfHit(m),
275
+ chatId: m.chatId,
276
+ id: m.id,
277
+ }));
278
+ const out = new Map();
279
+ if (!only.length)
280
+ return out;
281
+ const eligible = await searchLucene(store, account, { ...request, text: scope || undefined, ast: undefined, limit: only.length, only, context: 0, thread: undefined }, messenger);
282
+ const allowed = new Map(eligible.items.map((m) => [m.locator, m]));
283
+ const shown = new Set();
284
+ head.forEach((hit, i) => {
285
+ shown.add(hit.locator);
286
+ const picked = (later[i] ?? [])
287
+ .flatMap((m) => allowed.get(m.locator) ?? [])
288
+ .slice(0, FOLLOW_UPS_PER_HIT)
289
+ .filter((m) => !shown.has(m.locator));
290
+ for (const m of picked)
291
+ shown.add(m.locator);
292
+ if (picked.length)
293
+ out.set(hit.locator, picked);
294
+ });
295
+ return out;
296
+ };
250
297
  /** The nearest messages the question's filters allow, as hits, nearest first. */
251
298
  export const nearEligible = async (store, account, request, messenger, ranked, scope) => {
252
299
  const keyOf = (one) => JSON.stringify([one.provider, one.account, one.chatId, one.id]);
@@ -12,6 +12,8 @@ export interface QueryMetadata {
12
12
  candidateDepth: number;
13
13
  candidates: number;
14
14
  repliesAdded: number;
15
+ /** Later messages shown with the first hits because they share a rare word with one. */
16
+ followUps: number;
15
17
  truncated: boolean;
16
18
  terms: string[];
17
19
  queries: string[];
@@ -97,6 +97,8 @@ export type FoundMessage = StoredHit & {
97
97
  exact?: boolean;
98
98
  source?: HitSource;
99
99
  context?: WindowedMessage[];
100
+ /** A discovery hit's later messages that share a rare word with it, latest first; one may change or cancel it. */
101
+ followUps?: FoundMessage[];
100
102
  thread?: ThreadContext;
101
103
  };
102
104
  export interface SearchFound extends Page<FoundMessage> {
@@ -4,6 +4,7 @@ import type { MessengerTraits } from "../ports/traits.js";
4
4
  import type { AccountKey, MessageStore } from "../store/store.js";
5
5
  import type { SearchFound, SearchQuery } from "./messages.js";
6
6
  import { type MeaningLane, type MeaningSkipped } from "./messages-discovery.js";
7
+ import type { SearchCoverage } from "./messages-search.js";
7
8
  import type { Backend, ServerOptions, ServerSearched } from "./server-search.js";
8
9
  export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes", "events"];
9
10
  export type SearchedResource = (typeof RESOURCES_SEARCHED)[number];
@@ -77,6 +78,8 @@ export interface SearchAllFound {
77
78
  };
78
79
  /** The messenger's server step for messages, as `search messages` reports it. */
79
80
  server?: ServerSearched;
81
+ /** What the store held of the chats the messages were searched in; absent when messages were not searched. */
82
+ coverage?: SearchCoverage;
80
83
  }
81
84
  export interface SearchAllRequest {
82
85
  text: string;
@@ -53,6 +53,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
53
53
  let events;
54
54
  let messagesBy;
55
55
  let server;
56
+ let coverage;
56
57
  const messagesOf = async (kind) => {
57
58
  const given = request.scope?.[kind];
58
59
  const query = {
@@ -76,6 +77,8 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
76
77
  const found = kind === "messages" ? await searchMessages(query) : await searchStore(store, account, query, messenger);
77
78
  if (found.server)
78
79
  server = found.server;
80
+ if (kind === "messages" && found.coverage)
81
+ coverage = found.coverage;
79
82
  hasMore ||= found.hasMore;
80
83
  const read = found.coverage?.accounts ??
81
84
  given ??
@@ -243,5 +246,6 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
243
246
  ...(events === undefined ? {} : { events }),
244
247
  ...(messagesBy === undefined ? {} : { messages: messagesBy }),
245
248
  ...(server === undefined ? {} : { server }),
249
+ ...(coverage === undefined ? {} : { coverage }),
246
250
  };
247
251
  };
@@ -157,6 +157,15 @@ export interface MessagesStore extends AccountMessages {
157
157
  chatId: Id;
158
158
  id: Id;
159
159
  }[], limit: number): Promise<Page<StoredHit>>;
160
+ /** Per message, later messages sharing a word at most `rareAt` messages hold, latest first; empty until stems are built. */
161
+ laterSharingRare(wanted: {
162
+ account: AccountKey;
163
+ chatId: Id;
164
+ id: Id;
165
+ }[], options: {
166
+ rareAt: number;
167
+ limit: number;
168
+ }): Promise<StoredHit[][]>;
160
169
  }
161
170
  export declare const accountMessagesOver: (context: StoreContext) => AccountMessages;
162
171
  export declare const messagesStoreOver: (context: StoreContext, keyed: AccountMessages) => MessagesStore;
@@ -1,6 +1,7 @@
1
1
  import { keysOver } from "./keys.js";
2
2
  import { applyCounterObservations } from "./sqlite/counters.js";
3
3
  import { documentLinkMessages } from "./sqlite/document-links.js";
4
+ import { laterSharingRare } from "./sqlite/follow-ups.js";
4
5
  import * as messageWrites from "./sqlite/messages.js";
5
6
  import * as personLinks from "./sqlite/person-links.js";
6
7
  import * as reads from "./sqlite/reads.js";
@@ -144,4 +145,5 @@ export const messagesStoreOver = (context, keyed) => ({
144
145
  search: async (query, { limit, account }) => search.find(context, { text: query, limit, ...(account ? { account } : {}) }),
145
146
  find: async (filter) => search.find(context, filter),
146
147
  directReplies: async (parents, limit) => search.directReplies(context, parents, limit),
148
+ laterSharingRare: async (wanted, options) => laterSharingRare(context, wanted, options),
147
149
  });
@@ -0,0 +1,17 @@
1
+ import type { AccountKey, StoredHit } from "../types.js";
2
+ import type { StoreContext } from "./open.js";
3
+ /**
4
+ * For each message, the later messages of the store that share a rare word with it, latest first, at most `limit`
5
+ * each. A word is rare when at most `rareAt` messages hold its stem. A message that cancels or changes a decision
6
+ * often names the same supplier, code or term without replying to it (cli-testing
7
+ * performance/search/evidence-pipeline, "Cancelled decisions and long messages"). Empty while the stems are not
8
+ * built, since their counts would be short.
9
+ */
10
+ export declare const laterSharingRare: (context: StoreContext, wanted: {
11
+ account: AccountKey;
12
+ chatId: string;
13
+ id: string;
14
+ }[], { rareAt, limit }: {
15
+ rareAt: number;
16
+ limit: number;
17
+ }) => StoredHit[][];
@@ -0,0 +1,59 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { hitsByPk } from "./search.js";
3
+ import { stemmerCache, stemsState, storeStemmers } from "./stems.js";
4
+ const stemmerFor = stemmerCache();
5
+ /**
6
+ * For each message, the later messages of the store that share a rare word with it, latest first, at most `limit`
7
+ * each. A word is rare when at most `rareAt` messages hold its stem. A message that cancels or changes a decision
8
+ * often names the same supplier, code or term without replying to it (cli-testing
9
+ * performance/search/evidence-pipeline, "Cancelled decisions and long messages"). Empty while the stems are not
10
+ * built, since their counts would be short.
11
+ */
12
+ export const laterSharingRare = (context, wanted, { rareAt, limit }) => {
13
+ if (wanted.length > 50 || !Number.isInteger(limit) || limit < 1 || limit > 100)
14
+ throw new CliError("validation_error", "a follow-up lookup takes at most 50 messages and 1–100 results each");
15
+ const { database } = context;
16
+ const stemmers = storeStemmers(database);
17
+ if (!wanted.length || !stemmers || !stemsState(database)?.ready)
18
+ return wanted.map(() => []);
19
+ const stemmer = stemmerFor(stemmers);
20
+ // A temporary table: reading the index's own counts needs no migration, and it leaves with the connection.
21
+ database.exec("CREATE VIRTUAL TABLE IF NOT EXISTS temp.message_stem_counts USING fts5vocab(main, message_stems, col)");
22
+ const count = database.prepare("SELECT doc FROM temp.message_stem_counts WHERE term = ? AND col = 'stems'");
23
+ const counts = new Map();
24
+ const countOf = (stem) => {
25
+ let found = counts.get(stem);
26
+ if (found === undefined) {
27
+ found = Number(count.get(stem)?.doc ?? 0);
28
+ counts.set(stem, found);
29
+ }
30
+ return found;
31
+ };
32
+ const message = database.prepare(`
33
+ SELECT m.text, m.sent_at FROM accounts ac
34
+ JOIN chats c ON c.account_id = ac.id AND c.external_id = ?
35
+ JOIN messages m ON m.chat_id = c.id AND m.external_id = ?
36
+ WHERE ac.provider = ? AND ac.external_id = ? AND m.deleted_at IS NULL
37
+ `);
38
+ const later = database.prepare(`
39
+ SELECT m.id FROM message_stems s JOIN messages m ON m.id = s.rowid
40
+ WHERE message_stems MATCH ? AND m.deleted_at IS NULL AND m.sent_at > ?
41
+ ORDER BY m.sent_at DESC, m.id DESC LIMIT ?
42
+ `);
43
+ return wanted.map(({ account, chatId, id }) => {
44
+ const row = message.get(chatId, id, account.provider, account.account);
45
+ if (!row)
46
+ return [];
47
+ // Words of three letters or more, as the benchmark took them: a number or a code with digits is not a name here.
48
+ const stems = new Set(stemmer
49
+ .explain(String(row.text))
50
+ .filter(({ word }) => /^\p{L}{3,}$/u.test(word))
51
+ .map(({ word }) => stemmer.stemToken(word))
52
+ .filter(Boolean));
53
+ const rare = [...stems].filter((stem) => countOf(stem) <= rareAt);
54
+ if (!rare.length)
55
+ return [];
56
+ const match = `stems : (${rare.map((stem) => `"${stem.replaceAll('"', '""')}"`).join(" OR ")})`;
57
+ return hitsByPk(context, later.all(match, Number(row.sent_at), limit).map((one) => Number(one.id)));
58
+ });
59
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.491.0",
3
+ "version": "0.493.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",