@wirecat/cli-core 0.544.0 → 0.545.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
@@ -7,6 +7,18 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.545.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **History limits for pushed history, per profile.** A messenger whose history is pushed (`history: "store"`,
15
+ WhatsApp) saves one-to-one chats back `history_dialog_days` (1095 by default), groups of more than two people back
16
+ `history_group_days` (365) and nothing of a group above `history_group_members_max` people (50); at most 1095 days.
17
+ The chat row stays, so the chat is still listed. Live messages, `messages sync` and what the store already holds are
18
+ never dropped. `messages sync <chat> --full-history` fetches a chat and keeps its pushed history in full from then on
19
+ (`history_included` in the answer). `ChatsStore` gains `historySkips`, `historyShapes`, `recordHistorySkips` and
20
+ `setHistoryIncluded`; `Saving` takes `historyLimits`.
21
+
10
22
  ## 0.544.0 — 02.11.2026
11
23
 
12
24
  ### Added
@@ -12,11 +12,12 @@ 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
+ import { commandAt, lineOf } from "../steps.js";
16
16
  import { jobsCommand } from "./backfill-command.js";
17
17
  import { messengerContext } from "./context.js";
18
18
  import { admitPassword, linesOf, openFolder, runFileFor, saveManifest, sealRun, writeChanges } from "./export-folder.js";
19
19
  import { gapsCommand } from "./gaps-command.js";
20
+ import { historyLimitGaps } from "./history-gaps.js";
20
21
  import { messageItems } from "./message-items.js";
21
22
  import { ENCRYPT_OPTION, passwordOf } from "./password.js";
22
23
  import { storeMaintenanceCommands } from "./store-maintenance-command.js";
@@ -71,9 +72,26 @@ const statusCommand = (messenger) => annotate(new Command("status"), { output: {
71
72
  .action(async function (chat) {
72
73
  const context = messengerContext(this, messenger);
73
74
  const rows = await context.withServices((services) => services.archive.status(chat));
74
- renderAnswer(this, context, listOf("store status", { items: rows.map(snakeKeys), limit: Math.max(1, rows.length), has_more: false }));
75
+ const skips = await context
76
+ .withStore((store, account) => store.chats.historySkips(account))
77
+ .then((all) => chat === undefined ? all : all.filter((skip) => rows.some((row) => row.chatId === skip.chatId)))
78
+ .catch(() => []);
79
+ const limited = historyLimitGaps(skips);
80
+ renderAnswer(this, context, listOf("store status", {
81
+ items: rows.map(snakeKeys),
82
+ limit: Math.max(1, rows.length),
83
+ has_more: false,
84
+ next: limited.next,
85
+ missing: limited.missing,
86
+ }));
75
87
  if (rows.length === 0 && context.format === "pretty")
76
88
  context.renderer.note("the store holds no messages for this profile yet");
89
+ if (skips.length > 0 && context.format === "pretty")
90
+ context.renderer.note(`history limits left out pushed history of ${skips.length} chats — \`${lineOf(this, "messages sync <chat> --full-history")}\` fetches one and keeps it in full:\n` +
91
+ skips
92
+ .slice(0, 20)
93
+ .map((skip) => ` ${skip.title ?? skip.chatId} ${skip.messages} messages ${skip.reason}`)
94
+ .join("\n"));
77
95
  });
78
96
  const LISTED = 20;
79
97
  /** Titles are the owner's: these lines go to a person's terminal, never into an error message. */
@@ -58,14 +58,17 @@ export const syncCommand = (messenger) => {
58
58
  .option("--catch-up-chunks <n>", "at most this many local vector chunks", wholeNumber)
59
59
  .option("--catch-up-messages <n>", "skip a graph rebuild larger than this many messages", wholeNumber)
60
60
  .option("--catch-up-time <duration>", "local preparation time budget, 30s by default")
61
+ .option("--full-history", "keep this chat's pushed history in full from now on, past the history limits (history_* settings)")
61
62
  .option("--background", "run as a job that outlives this command; `store jobs show` follows it")
62
63
  .option("--dry-run", "only estimate how many messages, requests and minutes a full fetch would still take — from the store, no request")
63
64
  .action(async function (chat) {
64
- const { all, pause, sinceTime: since, last, background, dryRun: estimate, catchUp, catchUpChunks, catchUpMessages, catchUpTime, ...sizes } = this.opts();
65
+ const { all, pause, sinceTime: since, last, background, dryRun: estimate, catchUp, catchUpChunks, catchUpMessages, catchUpTime, fullHistory: include, ...sizes } = this.opts();
65
66
  if ((chat === undefined) === (all !== true))
66
67
  throw new CliError("validation_error", "name a chat, or --all for every chat");
67
68
  if (all && estimate)
68
69
  throw new CliError("validation_error", "--dry-run prices one chat; not with --all");
70
+ if (include && chat === undefined)
71
+ throw new CliError("validation_error", "--full-history keeps one chat: name it");
69
72
  if (all && messenger.history === "store") {
70
73
  throw new CliError("validation_error", `--all walks the messenger's chat list, and ${messenger.name ?? messenger.app.command} pushes its chats ` +
71
74
  `instead — keep \`${lineOf(this, "serve")}\` running${messenger.historyOnDemand ? ", or name one chat" : ""}`);
@@ -164,6 +167,10 @@ export const syncCommand = (messenger) => {
164
167
  const result = await context.withServices((services) => chat === undefined ? services.archive.fetchAll(options) : services.archive.fetch(chat, options));
165
168
  if (jobId)
166
169
  finishJob(messenger.app, jobs, jobId, { finishedAt: new Date().toISOString(), result });
170
+ const synced = "chat" in result ? result.chat : null;
171
+ const included = include && synced
172
+ ? await context.withStore((store, account) => store.chats.setHistoryIncluded(account, synced, true))
173
+ : undefined;
167
174
  const tasks = await taskRulesAfterSync(context);
168
175
  const prepared = "prepared" in result ? result.prepared : undefined;
169
176
  const unprepared = prepared && !prepared.complete && "chat" in result && result.chat !== null;
@@ -171,7 +178,11 @@ export const syncCommand = (messenger) => {
171
178
  const review = await context.withServices((services) => services.mentions.newCount(point.after));
172
179
  const toReview = review.count;
173
180
  const shown = `${toReview}${review.more ? "+" : ""}`;
174
- renderAnswer(this, context, itemOf(snakeKeys(tasks && tasks.added + tasks.closed + (tasks.held ?? 0) > 0 ? { ...result, tasks } : result), {
181
+ renderAnswer(this, context, itemOf(snakeKeys({
182
+ ...result,
183
+ ...(tasks && tasks.added + tasks.closed + (tasks.held ?? 0) > 0 ? { tasks } : {}),
184
+ ...(included === undefined ? {} : { historyIncluded: included }),
185
+ }), {
175
186
  next: [
176
187
  ...(unprepared
177
188
  ? [
@@ -231,6 +231,9 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
231
231
  recipientIds: () => (new RecipientList(recipientsPathFor(app, settings.profile, env), app.command).read() ?? []).flatMap((recipient) => [recipient.id, ...(recipient.partnerId ? [recipient.partnerId] : [])]),
232
232
  ...(history === "store" ? { namesFromStore: savedChatId ? { savedChatId } : {} } : {}),
233
233
  ...(unreadCounts === "store" ? { countsUnread: true } : {}),
234
+ ...(history === "store" && settings.historyLimits
235
+ ? { historyLimits: settings.historyLimits, command: `${app.command} ${settings.profile}` }
236
+ : {}),
234
237
  });
235
238
  const passives = history === "store" && self !== null && !watching
236
239
  ? passivesOf(saving).map((passive) => listened(passive, events, renderer.warn, closeWaitMs))
@@ -0,0 +1,12 @@
1
+ import type { Missing, NextStep } from "@wirecat/cli-base/standards";
2
+ import type { AccountKey, MessageStore, StoredHistorySkip } from "../../store/store.js";
3
+ /**
4
+ * What the history limits (`history_*` settings) left out of pushed history, as `missing` and `next`: an answer read
5
+ * from the store must not pass for every message the messenger has.
6
+ */
7
+ export declare const historyLimitGaps: (skips: StoredHistorySkip[]) => {
8
+ next: NextStep[];
9
+ missing: Missing[];
10
+ };
11
+ /** What the limits left out in every account an answer read, most messages first. */
12
+ export declare const historySkipsOf: (store: Pick<MessageStore, "chats">, accounts: readonly AccountKey[]) => Promise<StoredHistorySkip[]>;
@@ -0,0 +1,41 @@
1
+ import { nextStep } from "../steps.js";
2
+ const SHOWN = 5;
3
+ const reasonOf = (skip) => skip.reason === "group_too_large"
4
+ ? `a group of ${skip.members ?? "many"} people, not saved`
5
+ : `only from ${skip.keptFrom?.slice(0, 10) ?? "the window's start"}`;
6
+ const nameOf = (skip) => skip.title ?? skip.chatId;
7
+ /**
8
+ * What the history limits (`history_*` settings) left out of pushed history, as `missing` and `next`: an answer read
9
+ * from the store must not pass for every message the messenger has.
10
+ */
11
+ export const historyLimitGaps = (skips) => {
12
+ const [first] = skips;
13
+ if (!first)
14
+ return { next: [], missing: [] };
15
+ const messages = skips.reduce((sum, skip) => sum + skip.messages, 0);
16
+ const listed = skips
17
+ .slice(0, SHOWN)
18
+ .map((skip) => `${nameOf(skip)} (${skip.messages}, ${reasonOf(skip)})`)
19
+ .join("; ");
20
+ const more = skips.length > SHOWN ? `; and ${skips.length - SHOWN} more — \`store status\` lists them` : "";
21
+ return {
22
+ next: [
23
+ {
24
+ ...nextStep("messages.sync", { args: { chat: first.chatId, full_history: true } }),
25
+ why: "fetches this chat's history and keeps it in full from now on, past the history limits",
26
+ },
27
+ ],
28
+ missing: [
29
+ {
30
+ field: "history_limits",
31
+ why: `the history limits (history_* settings) left ${messages} pushed messages of ${skips.length} chats out of ` +
32
+ `the store: ${listed}${more}`,
33
+ step: "messages.sync",
34
+ },
35
+ ],
36
+ };
37
+ };
38
+ /** What the limits left out in every account an answer read, most messages first. */
39
+ export const historySkipsOf = async (store, accounts) => (await Promise.all(accounts.map((account) => store.chats.historySkips(account))))
40
+ .flat()
41
+ .sort((a, b) => b.messages - a.messages);
@@ -18,6 +18,7 @@ import { messengerContext } from "./context.js";
18
18
  import { linksCommand } from "./conversations-command.js";
19
19
  import { downloadSubcommand } from "./download-command.js";
20
20
  import { heardItems, hearForCommand, hearingMissing, MODEL_OPTION, spokenItems, TRANSCRIBE_OPTION, } from "./hearing-command.js";
21
+ import { historyLimitGaps } from "./history-gaps.js";
21
22
  import { cursorOf, historyQuery, historyWindow } from "./history-query.js";
22
23
  import { hideNever } from "./lacks.js";
23
24
  import { threadAnswer } from "./message-answers.js";
@@ -74,9 +75,12 @@ export const messagesCommand = (messenger) => {
74
75
  : undefined;
75
76
  // Offline, the store is the whole answer: say how much of the chat it holds. Online the messenger is read.
76
77
  const gaps = context.settings.offline
77
- ? coverageGaps(await context.withStore(async (store, account) => chatCoverage(store, account, [
78
- await storedChatId(messenger, chat, store, account, { includeLeft: true }),
79
- ])))
78
+ ? await context.withStore(async (store, account) => {
79
+ const chatId = await storedChatId(messenger, chat, store, account, { includeLeft: true });
80
+ const held = coverageGaps(await chatCoverage(store, account, [chatId]));
81
+ const limited = historyLimitGaps((await store.chats.historySkips(account)).filter((skip) => skip.chatId === chatId));
82
+ return { next: [...limited.next, ...held.next], missing: [...limited.missing, ...held.missing] };
83
+ })
80
84
  : { next: [], missing: [] };
81
85
  renderAnswer(this, context, listOf("messages list", {
82
86
  items: messageItems(heardItems(page.items, hearing), reader),
@@ -12,6 +12,7 @@ import { renderAnswer } from "../envelope.js";
12
12
  import { positiveCount } from "../paging.js";
13
13
  import { archiveSummary } from "./archive-summary.js";
14
14
  import { messengerContext } from "./context.js";
15
+ import { historySkipsOf } from "./history-gaps.js";
15
16
  import { meaningNote, messagesAnswer, offsetOf, shownOf } from "./search-answers.js";
16
17
  import { backendOptions, backendRequest, noteServer } from "./search-backend-options.js";
17
18
  import { syncOptions, syncRequest, syncsFirst } from "./search-sync-options.js";
@@ -207,7 +208,10 @@ const searchAction = (messenger) => async function (given) {
207
208
  ...(newest ? { newest: true } : {}),
208
209
  ...(timezone === undefined ? {} : { timezone }),
209
210
  };
210
- renderAnswer(this, context, messagesAnswer("search messages", found, { limit, page: page ?? 1, again }, given.join(" ")), view);
211
+ const skips = await context
212
+ .withStore((store) => historySkipsOf(store, found.coverage?.accounts ?? found.accounts ?? []))
213
+ .catch(() => []);
214
+ renderAnswer(this, context, messagesAnswer("search messages", found, { limit, page: page ?? 1, again }, given.join(" "), skips), view);
211
215
  };
212
216
  const withOffset = (page, limit) => {
213
217
  const offset = offsetOf(page, limit);
@@ -8,6 +8,7 @@ import type { MeaningSkipped } from "../../services/messages-discovery.js";
8
8
  import type { FoundNote, NotesFoundOf } from "../../services/notes-search.js";
9
9
  import type { SearchAllItem } from "../../services/search-all.js";
10
10
  import type { MeetingSearchItem, SearchAllIncludingMeetingsFound } from "../../services/search-all-meetings.js";
11
+ import type { StoredHistorySkip } from "../../store/store.js";
11
12
  import { type ListBody } from "../envelope.js";
12
13
  /** What a source tool's `search all` reads: never mail, which is memo's (docs/dev/TOOL-BOUNDARIES.md). */
13
14
  export declare const SOURCE_SEARCHED: readonly ["messages", "notes", "events", "meetings"];
@@ -42,10 +43,10 @@ export interface Paging {
42
43
  /** "showing 20 of 144", for the human view under a page of hits; `+` when the total is a lower bound. */
43
44
  export declare const shownOf: (shown: number, offset: number, total: SearchTotal | undefined) => string | undefined;
44
45
  /** `search messages` and `search mail`, CLI and MCP alike. */
45
- export declare const messagesAnswer: (command: string, found: SearchFound, { limit, page, again }: Paging, query?: string) => ListBody<MessageHitItem>;
46
+ export declare const messagesAnswer: (command: string, found: SearchFound, { limit, page, again }: Paging, query?: string, skips?: StoredHistorySkip[]) => ListBody<MessageHitItem>;
46
47
  export declare const noteHitItem: (hit: FoundNote) => NoteHitItem;
47
48
  export declare const allHitItem: (item: SearchAllItem | MeetingSearchItem) => HitItem;
48
49
  /** `search notes`, CLI and MCP alike. */
49
50
  export declare const notesAnswer: (command: string, found: NotesFoundOf, { limit, page, again }: Paging, exact?: boolean) => ListBody<NoteHitItem>;
50
51
  /** `search all`, CLI and MCP alike. A bounded meeting scan that could not tell whether more match says so. */
51
- export declare const allAnswer: (command: string, found: SearchAllIncludingMeetingsFound, { limit, page, again }: Paging, exact?: boolean) => ListBody<HitItem>;
52
+ export declare const allAnswer: (command: string, found: SearchAllIncludingMeetingsFound, { limit, page, again }: Paging, exact?: boolean, skips?: StoredHistorySkip[]) => ListBody<HitItem>;
@@ -7,6 +7,7 @@ import { MAX_SEARCH_OFFSET } from "../../services/messages-archive-search.js";
7
7
  import { listOf } from "../envelope.js";
8
8
  import { nextStep } from "../steps.js";
9
9
  import { archiveSummary } from "./archive-summary.js";
10
+ import { historyLimitGaps } from "./history-gaps.js";
10
11
  /** What a source tool's `search all` reads: never mail, which is memo's (docs/dev/TOOL-BOUNDARIES.md). */
11
12
  export const SOURCE_SEARCHED = ["messages", "notes", "events", "meetings"];
12
13
  const nonEmpty = (list) => (list.length > 0 ? list : undefined);
@@ -249,8 +250,10 @@ export const shownOf = (shown, offset, total) => total === undefined || shown ==
249
250
  ? undefined
250
251
  : `showing ${offset + 1}–${offset + shown} of ${total.value}${total.relation === "gte" ? "+" : ""}`;
251
252
  /** `search messages` and `search mail`, CLI and MCP alike. */
252
- export const messagesAnswer = (command, found, { limit, page, again }, query = "") => {
253
+ export const messagesAnswer = (command, found, { limit, page, again }, query = "", skips = []) => {
253
254
  const accounts = found.coverage?.accounts ?? found.accounts ?? [];
255
+ const gaps = gapsOf(found);
256
+ const limited = historyLimitGaps(skips);
254
257
  return listOf(command, {
255
258
  items: [...found.items.map((hit) => messageHitItem(hit, query)), ...pictureItems(found)],
256
259
  limit,
@@ -258,8 +261,9 @@ export const messagesAnswer = (command, found, { limit, page, again }, query = "
258
261
  page: page ?? 1,
259
262
  ...(found.total ? { total: found.total } : {}),
260
263
  ...(again ? { args: again } : {}),
261
- ...gapsOf(found),
262
- meta: metaOf(accounts, found.skipped, coverageGaps(found).missing.length > 0),
264
+ next: [...gaps.next, ...limited.next],
265
+ missing: [...gaps.missing, ...limited.missing],
266
+ meta: metaOf(accounts, found.skipped, coverageGaps(found).missing.length > 0 || skips.length > 0),
263
267
  });
264
268
  };
265
269
  /**
@@ -327,7 +331,8 @@ export const notesAnswer = (command, found, { limit, page, again }, exact = fals
327
331
  meta: metaOf(found.accounts, found.skipped),
328
332
  });
329
333
  /** `search all`, CLI and MCP alike. A bounded meeting scan that could not tell whether more match says so. */
330
- export const allAnswer = (command, found, { limit, page, again }, exact = false) => {
334
+ export const allAnswer = (command, found, { limit, page, again }, exact = false, skips = []) => {
335
+ const limited = historyLimitGaps(skips);
331
336
  const scan = found.meetings?.complete === false ? found.meetings : undefined;
332
337
  const notes = meaningGap(found.notes?.meaningSkipped, exact);
333
338
  const messages = messageMeaningGap(found.messages?.meaningSkipped, "found_by");
@@ -351,6 +356,7 @@ export const allAnswer = (command, found, { limit, page, again }, exact = false)
351
356
  ...notes.next,
352
357
  ...messages.next.filter((step) => !notes.next.some((asked) => JSON.stringify(asked) === JSON.stringify(step))),
353
358
  ...archive.next,
359
+ ...limited.next,
354
360
  ],
355
361
  missing: [
356
362
  ...(scan
@@ -365,7 +371,8 @@ export const allAnswer = (command, found, { limit, page, again }, exact = false)
365
371
  ...notes.missing,
366
372
  ...messages.missing,
367
373
  ...archive.missing,
374
+ ...limited.missing,
368
375
  ],
369
- meta: metaOf(found.accounts, found.skipped, archive.missing.length > 0),
376
+ meta: metaOf(found.accounts, found.skipped, archive.missing.length > 0 || skips.length > 0),
370
377
  });
371
378
  };
@@ -10,6 +10,7 @@ import { runsSearchCommand } from "../runs/search-command.js";
10
10
  import { commandAt } from "../steps.js";
11
11
  import { messengerContext } from "./context.js";
12
12
  import { conversationsSearchCommand } from "./conversations-command.js";
13
+ import { historySkipsOf } from "./history-gaps.js";
13
14
  import { messagesSearchCommand } from "./messages-search-command.js";
14
15
  import { allAnswer, meaningNote, notesAnswer, offsetOf, SOURCE_SEARCHED, shownOf, } from "./search-answers.js";
15
16
  import { backendOptions, backendRequest, noteServer } from "./search-backend-options.js";
@@ -92,7 +93,8 @@ const allCommand = (messenger) => {
92
93
  ...(exact ? { exact: true } : {}),
93
94
  ...(timezone === undefined ? {} : { timezone }),
94
95
  };
95
- renderAnswer(this, context, allAnswer("search all", found, { limit, page: page ?? 1, again }), () => {
96
+ const skips = await context.withStore((store) => historySkipsOf(store, found.accounts)).catch(() => []);
97
+ renderAnswer(this, context, allAnswer("search all", found, { limit, page: page ?? 1, again }, false, skips), () => {
96
98
  if (found.items.length === 0)
97
99
  context.renderer.note(found.server && !found.server.skipped
98
100
  ? "nothing found in the local store or on the messenger's server"
@@ -2,6 +2,7 @@ import type { Id } from "../../domain/models.js";
2
2
  import { type MessengerAdapter } from "../../ports/messenger.js";
3
3
  import type { MessengerTraits } from "../../ports/traits.js";
4
4
  import type { EventSink, Warn } from "../../runs/events.js";
5
+ import { type HistoryLimits } from "../../services/history-limits.js";
5
6
  import type { AccountKey, DeletionScope, MessageStore } from "../../store/store.js";
6
7
  export interface Saving {
7
8
  account: AccountKey;
@@ -41,6 +42,10 @@ export interface Saving {
41
42
  namesFromStore?: Pick<MessengerTraits, "savedChatId">;
42
43
  /** `MessengerTraits.unreadCounts: "store"`: a message `watch` or `listen` saves raises its chat's unread count. */
43
44
  countsUnread?: boolean;
45
+ /** Pushed history (`feed`, `listenHistory`) outside these is not saved, and each chat records what it left out. */
46
+ historyLimits?: HistoryLimits;
47
+ /** The app command and profile, for the warning that names the command listing what the limits left out. */
48
+ command?: string;
44
49
  }
45
50
  /**
46
51
  * The adapter, with what each read answers written to the store. **A failed write never fails the
@@ -50,4 +55,4 @@ export interface Saving {
50
55
  * would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
51
56
  * known to be whole, and `saveMembers` replaces the list.
52
57
  */
53
- export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing, closing, namesFromStore, recipientIds, countsUnread, }: Saving) => MessengerAdapter;
58
+ export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing, closing, namesFromStore, recipientIds, countsUnread, historyLimits, command, }: Saving) => MessengerAdapter;
@@ -2,6 +2,7 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { capability, throughWrapper, } from "../../ports/messenger.js";
3
3
  import { foldName, isId, pickPerson } from "../../resolve.js";
4
4
  import { codeOf } from "../../sends/guarded.js";
5
+ import { limitBatch } from "../../services/history-limits.js";
5
6
  import { chatIdIn } from "../../services/message-resolution.js";
6
7
  const REKEY_PACING = { lockMs: 50, pauseMs: 25 };
7
8
  /**
@@ -12,8 +13,37 @@ const REKEY_PACING = { lockMs: 50, pauseMs: 25 };
12
13
  * would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
13
14
  * known to be whole, and `saveMembers` replaces the list.
14
15
  */
15
- export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing = REKEY_PACING, closing, namesFromStore, recipientIds, countsUnread = false, }) => {
16
+ export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing = REKEY_PACING, closing, namesFromStore, recipientIds, countsUnread = false, historyLimits, command = "cli", }) => {
16
17
  let warned = false;
18
+ let limitedNoted = false;
19
+ const limited = async (opened, batch) => {
20
+ if (!historyLimits || !batch.messages?.length)
21
+ return { batch, skipped: new Map() };
22
+ const shapes = await opened.chats.historyShapes(account, [...new Set(batch.messages.map((one) => one.chatId))]);
23
+ return limitBatch(batch, (chatId) => shapes.get(chatId), (chatId) => shapes.get(chatId)?.included === true, historyLimits);
24
+ };
25
+ const noteLimited = (skipped) => {
26
+ if (skipped.size === 0)
27
+ return;
28
+ const counts = {
29
+ chats: skipped.size,
30
+ messages: 0,
31
+ group_too_large: 0,
32
+ older_than_window: 0,
33
+ };
34
+ for (const skip of skipped.values()) {
35
+ counts.messages = (counts.messages ?? 0) + skip.messages;
36
+ counts[skip.reason] = (counts[skip.reason] ?? 0) + 1;
37
+ }
38
+ const warning = { event: "warning", code: "history_limited", operation: "history.save", counts };
39
+ events(warning);
40
+ if (limitedNoted)
41
+ return;
42
+ limitedNoted = true;
43
+ warn(`history limits left out ${counts.messages} pushed messages of ${skipped.size} chats (big groups, older ` +
44
+ `than the window) — \`${command} store status\` lists them; \`${command} messages sync <chat> --full-history\` ` +
45
+ "fetches one and keeps it in full", warning);
46
+ };
17
47
  let recorded = profile === undefined;
18
48
  const notWritten = (operation, error) => {
19
49
  const warning = { event: "warning", code: "store_not_written", operation };
@@ -155,7 +185,13 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
155
185
  // A refused re-key leaves its chat split, never the others or the batch's history unsaved.
156
186
  const saving = previous.then(async () => {
157
187
  await saveRekeys(rekeys, round);
158
- return attempt(operation, (opened) => fill(opened, account, batch));
188
+ return attempt(operation, async (opened) => {
189
+ const { batch: kept, skipped } = await limited(opened, batch);
190
+ await fill(opened, account, kept);
191
+ if (skipped.size > 0)
192
+ await opened.chats.recordHistorySkips(account, skipped);
193
+ noteLimited(skipped);
194
+ });
159
195
  });
160
196
  previous = saving;
161
197
  pending?.add(saving);
@@ -4,6 +4,7 @@ import { type AISettings } from "../analysis/settings.js";
4
4
  import { type AppIdentity } from "../ports/app.js";
5
5
  import type { RecordInput } from "../runs/run-context.js";
6
6
  import { type Level, type PermissionKey } from "../sends/permissions.js";
7
+ import { type HistoryLimits } from "../services/history-limits.js";
7
8
  import { type ConfigIssue } from "./config-issues.js";
8
9
  import type { CommandPermissions } from "./permission-keys.js";
9
10
  export declare const plain: (rule: string) => (issue: v.BaseIssue<unknown>) => string;
@@ -94,6 +95,8 @@ export interface Settings extends AISettings {
94
95
  color: boolean | undefined;
95
96
  senderColors: boolean;
96
97
  searchCatchUp?: boolean;
98
+ /** How much pushed history the store keeps, by chat kind; a messenger that answers history on request ignores it. */
99
+ historyLimits?: HistoryLimits;
97
100
  /** Search and `images embed` read the pictures themselves, with a model on this machine. Off until set. */
98
101
  pictureSearch?: boolean;
99
102
  /** The reranker that orders `search messages --discover`'s first hits once it is downloaded, or `off`. */
@@ -300,6 +303,9 @@ export declare const settingsFor: <T extends object = Record<never, never>>(app:
300
303
  transcribe_with: v.OptionalSchema<v.PicklistSchema<["auto", "messenger", "local"], (issue: v.BaseIssue<unknown>) => string>, undefined>;
301
304
  speech_model: v.OptionalSchema<v.StringSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
302
305
  search_catch_up: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
306
+ history_dialog_days: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>, v.MaxValueAction<number, 1095, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
307
+ history_group_days: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>, v.MaxValueAction<number, 1095, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
308
+ history_group_members_max: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
303
309
  picture_search: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
304
310
  search_reranker: v.OptionalSchema<v.PicklistSchema<[...string[], "off"], (issue: v.BaseIssue<unknown>) => string>, undefined>;
305
311
  update_check: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
@@ -376,6 +382,9 @@ export declare const settingsFor: <T extends object = Record<never, never>>(app:
376
382
  transcribe_with: v.OptionalSchema<v.PicklistSchema<["auto", "messenger", "local"], (issue: v.BaseIssue<unknown>) => string>, undefined>;
377
383
  speech_model: v.OptionalSchema<v.StringSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
378
384
  search_catch_up: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
385
+ history_dialog_days: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>, v.MaxValueAction<number, 1095, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
386
+ history_group_days: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>, v.MaxValueAction<number, 1095, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
387
+ history_group_members_max: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
379
388
  picture_search: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
380
389
  search_reranker: v.OptionalSchema<v.PicklistSchema<[...string[], "off"], (issue: v.BaseIssue<unknown>) => string>, undefined>;
381
390
  }, (issue: v.StrictObjectIssue) => string>, "has to be an object of profiles, by name">, {}>;
@@ -7,6 +7,7 @@ import { DEFAULT_RERANK_MODEL, RERANK_MODELS } from "../embeddings/models.js";
7
7
  import { envName, readOnlyLockOf } from "../ports/app.js";
8
8
  import { KEEP_SUCCESSFUL_RUNS_FOR_DAYS, RUNS_SIZE_LIMIT_MB } from "../runs/run.js";
9
9
  import { assertOverridesTighten, CEILING_KEY, COMMAND_PATH_KEY, LEVELS, layerPermissions, permissionOverrides, RESOURCE_KEY, RESOURCES, } from "../sends/permissions.js";
10
+ import { DEFAULT_HISTORY_LIMITS, HISTORY_MAX_DAYS } from "../services/history-limits.js";
10
11
  import { storePath } from "../store/path.js";
11
12
  import { sharedConfigPath } from "../store/shared-config.js";
12
13
  import { checkConfigText, issueMessage, readConfigFile, readConfigText, savedPermissionKeys, schemaIssues, settingPathOf, withoutPath, } from "./config-issues.js";
@@ -34,6 +35,8 @@ export const count = v.pipe(v.number(wholeNumber), v.integer(wholeNumber), v.min
34
35
  const zeroOrMore = plain("has to be a whole number, 0 or more");
35
36
  const countOrZero = v.pipe(v.number(zeroOrMore), v.integer(zeroOrMore), v.minValue(0, zeroOrMore));
36
37
  export const flag = v.boolean(plain("has to be true or false"));
38
+ const historyDaysMessage = plain(`has to be a whole number of days, 1 to ${HISTORY_MAX_DAYS}`);
39
+ const historyDays = v.pipe(v.number(historyDaysMessage), v.integer(historyDaysMessage), v.minValue(1, historyDaysMessage), v.maxValue(HISTORY_MAX_DAYS, historyDaysMessage));
37
40
  /** `record_input`: true, false, or "failed" — the words typed kept only in a run that failed. */
38
41
  export const recordInputSetting = v.union([v.boolean(), v.literal("failed")], plain('has to be true, false or "failed"'));
39
42
  const wholeOrZero = (text, name) => {
@@ -71,6 +74,9 @@ const SHARED_PROFILE_ENTRIES = {
71
74
  transcribe_with: v.optional(v.picklist(["auto", "messenger", "local"], plain("has to be auto, messenger or local"))),
72
75
  speech_model: v.optional(v.string(plain("has to be a model id from `models list`, in quotes"))),
73
76
  search_catch_up: v.optional(flag),
77
+ history_dialog_days: v.optional(historyDays),
78
+ history_group_days: v.optional(historyDays),
79
+ history_group_members_max: v.optional(count),
74
80
  picture_search: v.optional(flag),
75
81
  search_reranker: v.optional(v.picklist([...RERANK_MODELS.map((model) => model.id), "off"], plain(`has to be off or a reranker id: ${RERANK_MODELS.map((model) => model.id).join(", ")}`))),
76
82
  ...AI_ENTRIES,
@@ -91,6 +97,9 @@ export const MESSENGER_ONLY_SETTINGS = [
91
97
  "sends_per_hour",
92
98
  "transcribe_with",
93
99
  "search_catch_up",
100
+ "history_dialog_days",
101
+ "history_group_days",
102
+ "history_group_members_max",
94
103
  "read_other_bots",
95
104
  ];
96
105
  /** One program, one version: whether to look for a newer one is not a per-profile matter. */
@@ -255,6 +264,9 @@ export const settingsFor = (app, extension = {}) => {
255
264
  const color = fromLayers("color", undefined);
256
265
  const senderColors = fromLayers("sender_colors", false);
257
266
  const searchCatchUp = fromLayers("search_catch_up", false);
267
+ const historyDialogDays = fromLayers("history_dialog_days", DEFAULT_HISTORY_LIMITS.dialogDays);
268
+ const historyGroupDays = fromLayers("history_group_days", DEFAULT_HISTORY_LIMITS.groupDays);
269
+ const historyGroupMembersMax = fromLayers("history_group_members_max", DEFAULT_HISTORY_LIMITS.groupMembersMax);
258
270
  const pictureSearch = fromLayers("picture_search", false);
259
271
  const searchReranker = fromLayers("search_reranker", DEFAULT_RERANK_MODEL);
260
272
  const recordInput = fromLayers("record_input", true);
@@ -306,6 +318,11 @@ export const settingsFor = (app, extension = {}) => {
306
318
  color: color.value,
307
319
  senderColors: senderColors.value,
308
320
  searchCatchUp: searchCatchUp.value,
321
+ historyLimits: {
322
+ dialogDays: historyDialogDays.value,
323
+ groupDays: historyGroupDays.value,
324
+ groupMembersMax: historyGroupMembersMax.value,
325
+ },
309
326
  pictureSearch: pictureSearch.value,
310
327
  searchReranker: searchReranker.value,
311
328
  limit: limit.value,
@@ -343,6 +360,9 @@ export const settingsFor = (app, extension = {}) => {
343
360
  color: color.from,
344
361
  senderColors: senderColors.from,
345
362
  searchCatchUp: searchCatchUp.from,
363
+ historyDialogDays: historyDialogDays.from,
364
+ historyGroupDays: historyGroupDays.from,
365
+ historyGroupMembersMax: historyGroupMembersMax.from,
346
366
  pictureSearch: pictureSearch.from,
347
367
  searchReranker: searchReranker.from,
348
368
  record: record.from,
@@ -82,6 +82,9 @@ export declare const appDoctorCommand: (app: import("./entry.js").AppIdentity, c
82
82
  transcribe_with: import("valibot").OptionalSchema<import("valibot").PicklistSchema<["auto", "messenger", "local"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
83
83
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
84
84
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
85
+ history_dialog_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
86
+ history_group_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
87
+ history_group_members_max: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
85
88
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
86
89
  search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
87
90
  update_check: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
@@ -158,6 +161,9 @@ export declare const appDoctorCommand: (app: import("./entry.js").AppIdentity, c
158
161
  transcribe_with: import("valibot").OptionalSchema<import("valibot").PicklistSchema<["auto", "messenger", "local"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
159
162
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
160
163
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
164
+ history_dialog_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
165
+ history_group_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
166
+ history_group_members_max: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
161
167
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
162
168
  search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
163
169
  }, (issue: import("valibot").StrictObjectIssue) => string>, "has to be an object of profiles, by name">, {}>;
@@ -256,6 +262,9 @@ export declare const appMcpCommand: (app: import("./entry.js").AppIdentity, conf
256
262
  transcribe_with: import("valibot").OptionalSchema<import("valibot").PicklistSchema<["auto", "messenger", "local"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
257
263
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
258
264
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
265
+ history_dialog_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
266
+ history_group_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
267
+ history_group_members_max: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
259
268
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
260
269
  search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
261
270
  update_check: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
@@ -332,6 +341,9 @@ export declare const appMcpCommand: (app: import("./entry.js").AppIdentity, conf
332
341
  transcribe_with: import("valibot").OptionalSchema<import("valibot").PicklistSchema<["auto", "messenger", "local"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
333
342
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
334
343
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
344
+ history_dialog_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
345
+ history_group_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
346
+ history_group_members_max: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
335
347
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
336
348
  search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
337
349
  }, (issue: import("valibot").StrictObjectIssue) => string>, "has to be an object of profiles, by name">, {}>;
@@ -437,6 +449,9 @@ export declare const configCommand: (app: import("./entry.js").AppIdentity, conf
437
449
  transcribe_with: import("valibot").OptionalSchema<import("valibot").PicklistSchema<["auto", "messenger", "local"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
438
450
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
439
451
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
452
+ history_dialog_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
453
+ history_group_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
454
+ history_group_members_max: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
440
455
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
441
456
  search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
442
457
  update_check: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
@@ -513,6 +528,9 @@ export declare const configCommand: (app: import("./entry.js").AppIdentity, conf
513
528
  transcribe_with: import("valibot").OptionalSchema<import("valibot").PicklistSchema<["auto", "messenger", "local"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
514
529
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
515
530
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
531
+ history_dialog_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
532
+ history_group_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
533
+ history_group_members_max: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
516
534
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
517
535
  search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
518
536
  }, (issue: import("valibot").StrictObjectIssue) => string>, "has to be an object of profiles, by name">, {}>;
@@ -646,6 +664,9 @@ export declare const completeCommand: (messenger: import("./index.js").Messenger
646
664
  transcribe_with: import("valibot").OptionalSchema<import("valibot").PicklistSchema<["auto", "messenger", "local"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
647
665
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
648
666
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
667
+ history_dialog_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
668
+ history_group_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
669
+ history_group_members_max: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
649
670
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
650
671
  search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
651
672
  update_check: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
@@ -722,6 +743,9 @@ export declare const completeCommand: (messenger: import("./index.js").Messenger
722
743
  transcribe_with: import("valibot").OptionalSchema<import("valibot").PicklistSchema<["auto", "messenger", "local"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
723
744
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
724
745
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
746
+ history_dialog_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
747
+ history_group_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MaxValueAction<number, 1095, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
748
+ history_group_members_max: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
725
749
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
726
750
  search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
727
751
  }, (issue: import("valibot").StrictObjectIssue) => string>, "has to be an object of profiles, by name">, {}>;
package/dist/cli/steps.js CHANGED
@@ -16,6 +16,7 @@ export const STEPS = defineSteps([
16
16
  { name: "all", required: false },
17
17
  { name: "since_time", required: false },
18
18
  { name: "background", required: false },
19
+ { name: "full_history", required: false },
19
20
  ],
20
21
  why: "the local copy is missing messages; syncing brings them in",
21
22
  },
@@ -1106,6 +1106,7 @@ export declare const ENTITIES: {
1106
1106
  readonly held: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
1107
1107
  readonly held_chats: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
1108
1108
  }, undefined>, undefined>;
1109
+ readonly history_included: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
1109
1110
  }, undefined>, v.StrictObjectSchema<{
1110
1111
  readonly batch: v.OptionalSchema<v.StrictObjectSchema<{
1111
1112
  readonly attempted: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
@@ -209,6 +209,8 @@ export declare const messageSyncChat: v.StrictObjectSchema<{
209
209
  readonly held: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
210
210
  readonly held_chats: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
211
211
  }, undefined>, undefined>;
212
+ /** `--full-history`: the chat's pushed history is now kept in full; `false` when the store does not know the chat. */
213
+ readonly history_included: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
212
214
  }, undefined>;
213
215
  export declare const messageSyncAll: v.StrictObjectSchema<{
214
216
  readonly batch: v.OptionalSchema<v.StrictObjectSchema<{
@@ -333,6 +335,8 @@ export declare const messageSync: v.UnionSchema<[v.StrictObjectSchema<{
333
335
  readonly held: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
334
336
  readonly held_chats: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
335
337
  }, undefined>, undefined>;
338
+ /** `--full-history`: the chat's pushed history is now kept in full; `false` when the store does not know the chat. */
339
+ readonly history_included: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
336
340
  }, undefined>, v.StrictObjectSchema<{
337
341
  readonly batch: v.OptionalSchema<v.StrictObjectSchema<{
338
342
  readonly attempted: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
@@ -60,6 +60,8 @@ export const messageSyncChat = v.strictObject({
60
60
  reached_last: v.optional(v.literal(true)),
61
61
  stopped: v.optional(v.literal(true)),
62
62
  tasks,
63
+ /** `--full-history`: the chat's pushed history is now kept in full; `false` when the store does not know the chat. */
64
+ history_included: v.optional(v.boolean()),
63
65
  });
64
66
  export const messageSyncAll = v.strictObject({
65
67
  batch: v.optional(batchResult),
@@ -0,0 +1,45 @@
1
+ import type { Id } from "../domain/models.js";
2
+ import type { HistoryBatch } from "../ports/messenger.js";
3
+ /** How much pushed history the store keeps of each kind of chat. */
4
+ export interface HistoryLimits {
5
+ /** One-to-one chats: days back from now. */
6
+ dialogDays: number;
7
+ /** Groups of more than two people: days back from now. */
8
+ groupDays: number;
9
+ /** Above this many people a group's pushed history is not kept at all. */
10
+ groupMembersMax: number;
11
+ }
12
+ export declare const HISTORY_MAX_DAYS = 1095;
13
+ export declare const DEFAULT_HISTORY_LIMITS: HistoryLimits;
14
+ export type HistorySkipReason = "group_too_large" | "older_than_window";
15
+ /** What the limits left out of one chat, summed over every push. */
16
+ export interface HistorySkip {
17
+ reason: HistorySkipReason;
18
+ messages: number;
19
+ oldestAt: string;
20
+ newestAt: string;
21
+ /** For `group_too_large`: how many people the group had. */
22
+ members?: number;
23
+ /** For `older_than_window`: the window's start when the push was saved. */
24
+ keptFrom?: string;
25
+ }
26
+ /** The kind and size of a chat, as the batch names it or the store holds it. */
27
+ export interface ChatShape {
28
+ kind: string;
29
+ participantsCount: number | null;
30
+ }
31
+ /** Whether this chat's pushed history is kept, and from when. A chat the owner lifted keeps everything. */
32
+ export declare const historyWindow: (shape: ChatShape | undefined, limits: HistoryLimits, now: number) => {
33
+ keepFrom: number;
34
+ } | {
35
+ skip: "group_too_large";
36
+ members: number;
37
+ };
38
+ /**
39
+ * The batch without the messages its chats' limits leave out, and what was left out per chat. Chat rows, people and
40
+ * re-keys always stay: a skipped chat is still listed, so its history can be fetched by hand.
41
+ */
42
+ export declare const limitBatch: (batch: HistoryBatch, shapes: (chatId: Id) => ChatShape | undefined, included: (chatId: Id) => boolean, limits: HistoryLimits, now?: number) => {
43
+ batch: HistoryBatch;
44
+ skipped: Map<Id, HistorySkip>;
45
+ };
@@ -0,0 +1,50 @@
1
+ export const HISTORY_MAX_DAYS = 1095;
2
+ export const DEFAULT_HISTORY_LIMITS = { dialogDays: 1095, groupDays: 365, groupMembersMax: 50 };
3
+ const DAY_MS = 24 * 60 * 60 * 1000;
4
+ /** Whether this chat's pushed history is kept, and from when. A chat the owner lifted keeps everything. */
5
+ export const historyWindow = (shape, limits, now) => {
6
+ const group = shape !== undefined && shape.kind !== "dialog" && shape.kind !== "saved";
7
+ if (group && (shape.participantsCount ?? 0) > limits.groupMembersMax)
8
+ return { skip: "group_too_large", members: shape.participantsCount ?? 0 };
9
+ const days = group ? limits.groupDays : limits.dialogDays;
10
+ return { keepFrom: now - Math.min(days, HISTORY_MAX_DAYS) * DAY_MS };
11
+ };
12
+ /**
13
+ * The batch without the messages its chats' limits leave out, and what was left out per chat. Chat rows, people and
14
+ * re-keys always stay: a skipped chat is still listed, so its history can be fetched by hand.
15
+ */
16
+ export const limitBatch = (batch, shapes, included, limits, now = Date.now()) => {
17
+ const skipped = new Map();
18
+ if (!batch.messages?.length)
19
+ return { batch, skipped };
20
+ const named = new Map((batch.chats ?? []).map((chat) => [chat.id, chat]));
21
+ const windows = new Map();
22
+ const windowOf = (chatId) => {
23
+ if (!windows.has(chatId)) {
24
+ const chat = named.get(chatId);
25
+ const shape = chat ? { kind: chat.kind, participantsCount: chat.participantsCount ?? null } : shapes(chatId);
26
+ windows.set(chatId, included(chatId) ? undefined : historyWindow(shape, limits, now));
27
+ }
28
+ return windows.get(chatId);
29
+ };
30
+ const messages = batch.messages.filter((message) => {
31
+ const window = windowOf(message.chatId);
32
+ if (window === undefined)
33
+ return true;
34
+ const at = Date.parse(message.timestamp);
35
+ if ("keepFrom" in window && !(at < window.keepFrom))
36
+ return true;
37
+ const reason = "skip" in window ? window.skip : "older_than_window";
38
+ const held = skipped.get(message.chatId);
39
+ const iso = new Date(at).toISOString();
40
+ skipped.set(message.chatId, {
41
+ reason,
42
+ messages: (held?.messages ?? 0) + 1,
43
+ oldestAt: held && held.oldestAt < iso ? held.oldestAt : iso,
44
+ newestAt: held && held.newestAt > iso ? held.newestAt : iso,
45
+ ...("skip" in window ? { members: window.members } : { keptFrom: new Date(window.keepFrom).toISOString() }),
46
+ });
47
+ return false;
48
+ });
49
+ return { batch: { ...batch, messages }, skipped };
50
+ };
@@ -1,8 +1,10 @@
1
1
  import type { ChannelTagMatch } from "../domain/channel-tags.js";
2
2
  import type { Chat, ChatKind, ChatUpdate, Id, Page } from "../domain/models.js";
3
+ import type { HistorySkip } from "../services/history-limits.js";
3
4
  import { type ChatRekey, type ChatRekeys } from "./sqlite/chat-rekey.js";
4
5
  import type { ChatMetadata, LeftChat } from "./sqlite/chats.js";
5
6
  import type { ChatCompleteness } from "./sqlite/completeness.js";
7
+ import type { ChatHistoryShape, StoredHistorySkip } from "./sqlite/history-skips.js";
6
8
  import type { StoreContext } from "./sqlite/open.js";
7
9
  import type { Where } from "./sqlite/query-filter.js";
8
10
  import type { AccountKey, ChatActivity, ChatStats, StoredChatFilter } from "./types.js";
@@ -64,6 +66,14 @@ export interface ChatsStore {
64
66
  * `chatId` itself. Only for finding the chat: the old id and its history are never shown or searched.
65
67
  */
66
68
  renamedChatId(key: AccountKey, chatId: Id): Promise<Id | undefined>;
69
+ /** Per stored chat: its kind, how many people, and whether the owner lifted its history limits. */
70
+ historyShapes(key: AccountKey, chatIds: Id[]): Promise<Map<Id, ChatHistoryShape>>;
71
+ /** Adds what the history limits left out of pushed history, per chat. */
72
+ recordHistorySkips(key: AccountKey, skips: Map<Id, HistorySkip>): Promise<void>;
73
+ /** Lifts the history limits for one chat, or puts them back; `false` when the store does not know the chat. */
74
+ setHistoryIncluded(key: AccountKey, chatId: Id, included: boolean): Promise<boolean>;
75
+ /** Every chat whose pushed history the limits left out, most messages first. */
76
+ historySkips(key: AccountKey): Promise<StoredHistorySkip[]>;
67
77
  /** A stored chat's kind, with or without messages; `undefined` when the store does not know the chat. */
68
78
  chatKind(key: AccountKey, chatId: Id): Promise<ChatKind | undefined>;
69
79
  /** A stored chat's title, with or without messages; `null` when it has none or the store does not know the chat. */
@@ -3,6 +3,7 @@ import { keysOver } from "./keys.js";
3
3
  import { rekeyChat, rekeyChats } from "./sqlite/chat-rekey.js";
4
4
  import * as chatQueries from "./sqlite/chats.js";
5
5
  import * as completeness from "./sqlite/completeness.js";
6
+ import * as history from "./sqlite/history-skips.js";
6
7
  import * as reads from "./sqlite/reads.js";
7
8
  import { write } from "./sqlite/transaction.js";
8
9
  export const chatsStoreOver = (context) => {
@@ -85,6 +86,30 @@ export const chatsStoreOver = (context) => {
85
86
  const accountKey = findAccountPk(key);
86
87
  return accountKey === undefined ? undefined : chatQueries.renamedChatId(context, accountKey, chatId);
87
88
  },
89
+ historyShapes: async (key, chatIds) => {
90
+ const accountKey = findAccountPk(key);
91
+ return accountKey === undefined ? new Map() : history.historyShapes(context, accountKey, chatIds);
92
+ },
93
+ recordHistorySkips: async (key, skips) => {
94
+ const accountKey = findAccountPk(key);
95
+ if (accountKey === undefined || skips.size === 0)
96
+ return;
97
+ write(database, () => history.recordHistorySkips(context, accountKey, skips));
98
+ },
99
+ setHistoryIncluded: async (key, chatId, included) => {
100
+ const accountKey = findAccountPk(key);
101
+ if (accountKey === undefined)
102
+ return false;
103
+ let done = false;
104
+ write(database, () => {
105
+ done = history.setHistoryIncluded(context, accountKey, chatId, included);
106
+ });
107
+ return done;
108
+ },
109
+ historySkips: async (key) => {
110
+ const accountKey = findAccountPk(key);
111
+ return accountKey === undefined ? [] : history.historySkips(context, accountKey);
112
+ },
88
113
  chatKind: async (key, chatId) => {
89
114
  const accountKey = findAccountPk(key);
90
115
  if (accountKey === undefined)
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { and, eq, inArray, or, sql } from "./drizzle/core.js";
3
3
  import { folded } from "./folded.js";
4
+ import { withoutHistory } from "./history-skips.js";
4
5
  import { identityPk } from "./identities.js";
5
6
  import { bound } from "./query-filter.js";
6
7
  import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
@@ -395,7 +396,7 @@ const toChat = (row, at, parentId) => ({
395
396
  scope: row.scope,
396
397
  parentChatId: parentId ?? null,
397
398
  membershipState: row.membershipState,
398
- providerMetadata: parsed(row.metadata),
399
+ providerMetadata: withoutHistory(parsed(row.metadata)),
399
400
  }),
400
401
  ...muteOf(row, at),
401
402
  ...present({ archived: flag(row.archived), markedUnread: flag(row.markedUnread) }),
@@ -0,0 +1,24 @@
1
+ import type { Id } from "../../domain/models.js";
2
+ import type { ChatShape, HistorySkip } from "../../services/history-limits.js";
3
+ import type { StoreContext } from "./open.js";
4
+ /**
5
+ * The history limits' record of a chat, kept in the chat row's `metadata` under this key so no store version is
6
+ * needed; `Chat.providerMetadata` leaves it out.
7
+ */
8
+ export declare const HISTORY_KEY = "$history";
9
+ export interface ChatHistoryShape extends ChatShape {
10
+ included: boolean;
11
+ }
12
+ export interface StoredHistorySkip extends HistorySkip {
13
+ chatId: Id;
14
+ title: string | null;
15
+ }
16
+ export declare const historyShapes: (context: StoreContext, accountKey: number, chatIds: Id[]) => Map<Id, ChatHistoryShape>;
17
+ /** Adds what one push left out to what the chat's record already holds. A chat the store lacks is left as it is. */
18
+ export declare const recordHistorySkips: (context: StoreContext, accountKey: number, skips: Map<Id, HistorySkip>) => void;
19
+ /** Lifts the limits for one chat from now on, or puts them back. Answers `false` for a chat the store lacks. */
20
+ export declare const setHistoryIncluded: (context: StoreContext, accountKey: number, chatId: Id, included: boolean) => boolean;
21
+ /** Every chat of the account whose pushed history the limits left out, most messages first. */
22
+ export declare const historySkips: ({ database }: StoreContext, accountKey: number) => StoredHistorySkip[];
23
+ /** `metadata` as the messenger gave it: the history record is the store's own. */
24
+ export declare const withoutHistory: <T extends Record<string, unknown> | undefined>(metadata: T) => T;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The history limits' record of a chat, kept in the chat row's `metadata` under this key so no store version is
3
+ * needed; `Chat.providerMetadata` leaves it out.
4
+ */
5
+ export const HISTORY_KEY = "$history";
6
+ const objectOf = (text) => {
7
+ if (typeof text !== "string")
8
+ return {};
9
+ try {
10
+ const value = JSON.parse(text);
11
+ return value !== null && typeof value === "object" && !Array.isArray(value)
12
+ ? value
13
+ : {};
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ };
19
+ const recordOf = (metadata) => {
20
+ const value = objectOf(metadata)[HISTORY_KEY];
21
+ return value !== null && typeof value === "object" ? value : {};
22
+ };
23
+ const rowOf = ({ database }, accountKey, chatId) => database
24
+ .prepare("SELECT id, kind, participants_count, metadata FROM chats WHERE account_id = ? AND external_id = ?")
25
+ .get(accountKey, chatId);
26
+ const update = (context, id, metadata, change) => {
27
+ const all = objectOf(metadata);
28
+ const next = change(recordOf(metadata));
29
+ if (next.skipped === undefined && !next.included)
30
+ delete all[HISTORY_KEY];
31
+ else
32
+ all[HISTORY_KEY] = next;
33
+ const text = Object.keys(all).length === 0 ? null : JSON.stringify(all);
34
+ context.database.prepare("UPDATE chats SET metadata = ?, updated_at = ? WHERE id = ?").run(text, context.now(), id);
35
+ };
36
+ export const historyShapes = (context, accountKey, chatIds) => {
37
+ const shapes = new Map();
38
+ for (const chatId of new Set(chatIds)) {
39
+ const row = rowOf(context, accountKey, chatId);
40
+ if (!row)
41
+ continue;
42
+ shapes.set(chatId, {
43
+ kind: String(row.kind),
44
+ participantsCount: row.participants_count == null ? null : Number(row.participants_count),
45
+ included: recordOf(row.metadata).included === true,
46
+ });
47
+ }
48
+ return shapes;
49
+ };
50
+ /** Adds what one push left out to what the chat's record already holds. A chat the store lacks is left as it is. */
51
+ export const recordHistorySkips = (context, accountKey, skips) => {
52
+ for (const [chatId, skip] of skips) {
53
+ const row = rowOf(context, accountKey, chatId);
54
+ if (!row)
55
+ continue;
56
+ update(context, Number(row.id), row.metadata, (held) => {
57
+ const before = held.skipped;
58
+ const skipped = before
59
+ ? {
60
+ ...skip,
61
+ messages: before.messages + skip.messages,
62
+ oldestAt: before.oldestAt < skip.oldestAt ? before.oldestAt : skip.oldestAt,
63
+ newestAt: before.newestAt > skip.newestAt ? before.newestAt : skip.newestAt,
64
+ }
65
+ : skip;
66
+ return { ...held, skipped };
67
+ });
68
+ }
69
+ };
70
+ /** Lifts the limits for one chat from now on, or puts them back. Answers `false` for a chat the store lacks. */
71
+ export const setHistoryIncluded = (context, accountKey, chatId, included) => {
72
+ const row = rowOf(context, accountKey, chatId);
73
+ if (!row)
74
+ return false;
75
+ update(context, Number(row.id), row.metadata, (held) => ({ ...held, included }));
76
+ return true;
77
+ };
78
+ /** Every chat of the account whose pushed history the limits left out, most messages first. */
79
+ export const historySkips = ({ database }, accountKey) => database
80
+ .prepare(`SELECT external_id, title, metadata FROM chats WHERE account_id = ? AND metadata LIKE '%"${HISTORY_KEY}"%'`)
81
+ .all(accountKey)
82
+ .flatMap((row) => {
83
+ const { skipped } = recordOf(row.metadata);
84
+ return skipped
85
+ ? [{ chatId: String(row.external_id), title: row.title == null ? null : String(row.title), ...skipped }]
86
+ : [];
87
+ })
88
+ .sort((a, b) => b.messages - a.messages);
89
+ /** `metadata` as the messenger gave it: the history record is the store's own. */
90
+ export const withoutHistory = (metadata) => {
91
+ if (metadata === undefined || !(HISTORY_KEY in metadata))
92
+ return metadata;
93
+ const { [HISTORY_KEY]: _, ...rest } = metadata;
94
+ return rest;
95
+ };
@@ -106,6 +106,7 @@ export interface MessageStore {
106
106
  forAccount(key: AccountKey): AccountStore;
107
107
  close(): Promise<void>;
108
108
  }
109
+ export type { ChatHistoryShape, StoredHistorySkip } from "./sqlite/history-skips.js";
109
110
  export { BACKFILL_ON_OPEN, type StoreOptions } from "./sqlite/open-store.js";
110
111
  export { fileFacts, readStoreTables, type TableKind } from "./sqlite/store-tables.js";
111
112
  export declare const openStore: (options?: StoreOptions) => Promise<MessageStore>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.544.0",
3
+ "version": "0.545.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
@@ -438,6 +438,9 @@
438
438
  "--full": {
439
439
  "meaning": "found messages with their whole text and every provider field"
440
440
  },
441
+ "--full-history": {
442
+ "meaning": "keep this chat's pushed history in full from now on, past the history limits"
443
+ },
441
444
  "--hidden": {
442
445
  "value": "<on|off>",
443
446
  "meaning": "on hides a forum's General topic from the topic list, off shows it"
@@ -3509,6 +3512,7 @@
3509
3512
  "--catch-up-messages": "all",
3510
3513
  "--catch-up-time": "all",
3511
3514
  "--dry-run": "all",
3515
+ "--full-history": "all",
3512
3516
  "--last": "all",
3513
3517
  "--limit": "all",
3514
3518
  "--no-catch-up": "all",