@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.
- package/CHANGELOG.md +58 -0
- package/dist/cli/messenger/conversation-answers.d.ts +7 -0
- package/dist/cli/messenger/message-answers.d.ts +7 -0
- package/dist/cli/messenger/message-writes.d.ts +14 -0
- package/dist/cli/messenger/messages-command.js +13 -1
- package/dist/cli/messenger/messages-search-command.js +4 -1
- package/dist/cli/messenger/search-answers.d.ts +6 -2
- package/dist/cli/messenger/search-answers.js +60 -7
- package/dist/cli/messenger/tasks-command.js +15 -3
- package/dist/cli/steps.js +1 -0
- package/dist/entities/agenda.d.ts +42 -0
- package/dist/entities/agenda.js +10 -0
- package/dist/entities/conversation-results.d.ts +14 -0
- package/dist/entities/evidence.d.ts +56 -0
- package/dist/entities/index.d.ts +169 -0
- package/dist/entities/index.js +1 -0
- package/dist/entities/listener.d.ts +28 -0
- package/dist/entities/message-context.d.ts +21 -0
- package/dist/entities/message-write.d.ts +28 -0
- package/dist/entities/message.d.ts +11 -0
- package/dist/entities/message.js +11 -0
- package/dist/entities/store-records.d.ts +7 -0
- package/dist/services/agenda.d.ts +5 -0
- package/dist/services/agenda.js +25 -4
- package/dist/services/chat-coverage.d.ts +19 -0
- package/dist/services/chat-coverage.js +27 -0
- package/dist/services/messages-discovery.d.ts +6 -0
- package/dist/services/messages-discovery.js +50 -3
- package/dist/services/messages-search.d.ts +2 -0
- package/dist/services/messages.d.ts +2 -0
- package/dist/services/search-all.d.ts +3 -0
- package/dist/services/search-all.js +4 -0
- package/dist/store/messages.d.ts +9 -0
- package/dist/store/messages.js +2 -0
- package/dist/store/sqlite/follow-ups.d.ts +17 -0
- package/dist/store/sqlite/follow-ups.js +59 -0
- package/package.json +1 -1
package/dist/services/agenda.js
CHANGED
|
@@ -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:
|
|
118
|
-
waiting:
|
|
119
|
-
unsynced:
|
|
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
|
|
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
|
};
|
package/dist/store/messages.d.ts
CHANGED
|
@@ -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;
|
package/dist/store/messages.js
CHANGED
|
@@ -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.
|
|
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",
|