@wirecat/cli-core 0.543.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cli/config-command.js +32 -2
  3. package/dist/cli/messenger/archive-commands.js +20 -2
  4. package/dist/cli/messenger/backfill-command.js +13 -2
  5. package/dist/cli/messenger/context.js +3 -0
  6. package/dist/cli/messenger/history-gaps.d.ts +12 -0
  7. package/dist/cli/messenger/history-gaps.js +41 -0
  8. package/dist/cli/messenger/messages-command.js +7 -3
  9. package/dist/cli/messenger/messages-search-command.js +5 -1
  10. package/dist/cli/messenger/search-answers.d.ts +3 -2
  11. package/dist/cli/messenger/search-answers.js +12 -5
  12. package/dist/cli/messenger/search-command.js +3 -1
  13. package/dist/cli/messenger/stored.d.ts +6 -1
  14. package/dist/cli/messenger/stored.js +38 -2
  15. package/dist/cli/program.js +22 -0
  16. package/dist/cli/settings.d.ts +9 -0
  17. package/dist/cli/settings.js +20 -0
  18. package/dist/cli/shared-command-exports.d.ts +24 -0
  19. package/dist/cli/skill-command.d.ts +2 -0
  20. package/dist/cli/skill-command.js +10 -0
  21. package/dist/cli/skill-refresh.d.ts +18 -0
  22. package/dist/cli/skill-refresh.js +82 -0
  23. package/dist/cli/steps.js +1 -0
  24. package/dist/entities/index.d.ts +1 -0
  25. package/dist/entities/message-transfer.d.ts +4 -0
  26. package/dist/entities/message-transfer.js +2 -0
  27. package/dist/services/history-limits.d.ts +45 -0
  28. package/dist/services/history-limits.js +50 -0
  29. package/dist/store/chats.d.ts +10 -0
  30. package/dist/store/chats.js +25 -0
  31. package/dist/store/sqlite/chats.js +2 -1
  32. package/dist/store/sqlite/history-skips.d.ts +24 -0
  33. package/dist/store/sqlite/history-skips.js +95 -0
  34. package/dist/store/store.d.ts +1 -0
  35. package/package.json +1 -1
  36. package/parity.json +4 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
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
+
22
+ ## 0.544.0 — 02.11.2026
23
+
24
+ ### Added
25
+
26
+ - **The first run of a new version refreshes the agent instructions an older version installed**: each `SKILL.md`
27
+ copy whose `skill status` is `stale` (Claude Code's, `~/.agents/skills`, and Hermes's own copy) is rewritten once,
28
+ with one line on stderr — `Updated the tg instructions for Claude Code and other agents to 0.112.0`. A missing copy
29
+ stays missing, an `unversioned` one (no released `skill install` wrote it) and a symlink are left as they are, and a
30
+ failure is one note, never a failed command. The version tried is kept in `<state>/skill-refresh.json`. Off in a
31
+ moved run, under vitest, during `skill`, and with `<PREFIX>_NO_SKILL_REFRESH=1`; `--quiet` keeps the line out.
32
+
33
+ ### Changed — may break callers
34
+
35
+ - **An agent's own shell may tighten a permission but never loosen it.** `config set|unset permissions…` run where
36
+ `AI_AGENT`, `CLAUDECODE`, `OPENCLAW_SHELL=exec` or another agent marker is set is now treated like an MCP agent's
37
+ call: `mcp.authority` and `bot.api` stay owner-only, and a level may only go down (`readonly` → `deny`, never
38
+ `readonly` → `allow`). Unsetting a permission, replacing `permissions` whole, or `--defaults`/`--personal`/`--bot`
39
+ accept only `deny`. Before, Hermes or OpenClaw could undo the read-only the desktop app set, from any chat message
40
+ that talked it into running `config set`. The owner changes them in a terminal outside the agent; the desktop app
41
+ already runs the tools without these variables. Not a sandbox: an agent with a general shell can still edit the
42
+ file itself, so keep its own command approvals on.
43
+
8
44
  ## 0.543.0 — 02.11.2026
9
45
 
10
46
  ### Added
@@ -2,7 +2,8 @@ import { CliError, pathsAreOverridden } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command, Option } from "commander";
4
4
  import { envName } from "../ports/app.js";
5
- import { AUTHORITY_KEY, BOT_API_KEY } from "../sends/permissions.js";
5
+ import { agentOf } from "../runs/run-context.js";
6
+ import { AUTHORITY_KEY, BOT_API_KEY, LEVELS, levelFor } from "../sends/permissions.js";
6
7
  import { baseContext, environmentOf } from "./context.js";
7
8
  import { grantsAuthority, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
8
9
  import { itemOf, renderAnswer } from "./envelope.js";
@@ -115,8 +116,13 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
115
116
  // The defaults are every other profile's settings too, and the top of the file is everyone's.
116
117
  throw new CliError("permission_error", `this process is locked to profile ${settings.profile} (${lock}) — ${root ? setting : "--defaults"} changes other profiles`);
117
118
  }
118
- if (environmentOf(this).agent)
119
+ // An agent's own shell counts too: Hermes and OpenClaw run the tool there for anyone their chat accepts (SEC-116).
120
+ if (environmentOf(this).agent || agentOf(env)) {
119
121
  refuseGuardedKey(setting, action === "set" ? String(given) : undefined, ownerOnly);
122
+ refuseLoosening(settings.permissions, setting, action === "set" ? String(given) : undefined, {
123
+ wide: Boolean(everyone || root || personal || bot),
124
+ });
125
+ }
120
126
  if (action === "set")
121
127
  refuseUnknownKey(this, setting, String(given), permissionKey);
122
128
  const saved = config.changeSetting(settings.configPath, {
@@ -185,6 +191,30 @@ const refuseGuardedKey = (setting, value, ownerOnly) => {
185
191
  return;
186
192
  throw new CliError("permission_error", `an agent cannot change permissions.${guarded} — the owner sets it in a terminal`, { permission: guarded });
187
193
  };
194
+ /**
195
+ * An agent may tighten a permission, never loosen one: a message in a chat the agent reads must not be able to talk it
196
+ * into lifting the owner's `readonly`. Removing a key, replacing `permissions` whole, or changing what several profiles
197
+ * get can loosen in ways this profile's levels cannot show, so there an agent may only set `deny`.
198
+ */
199
+ const refuseLoosening = (levels, setting, value, { wide }) => {
200
+ if (setting !== "permissions" && !setting.startsWith("permissions."))
201
+ return;
202
+ const key = setting.slice("permissions.".length);
203
+ const refuse = (why) => {
204
+ throw new CliError("permission_error", `an agent cannot loosen permissions: ${why} — the owner does it in a terminal`, {
205
+ permission: key || "permissions",
206
+ });
207
+ };
208
+ if (value === "deny" && setting !== "permissions")
209
+ return;
210
+ if (value === undefined)
211
+ refuse(`unsetting ${setting} can lift a limit`);
212
+ if (setting === "permissions" || wide)
213
+ refuse(`only deny may be set for ${setting} with --defaults, --personal, --bot or the whole object`);
214
+ const now = levelFor(levels, key).level;
215
+ if (LEVELS.indexOf(value) > LEVELS.indexOf(now))
216
+ refuse(`${setting} is ${now}, ${value} is looser`);
217
+ };
188
218
  /** The keys of a `permissions` object given whole; anything else is left to the schema to refuse. */
189
219
  const objectKeys = (value) => {
190
220
  try {
@@ -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);
@@ -31,6 +31,8 @@ import { commandWords, isProfileName, liftProfile, sampleCommand, takesProfile,
31
31
  import { absolutePaths, relayTarget } from "./relay-route.js";
32
32
  import { NoProfileHere, namedProfile, settingsFor, } from "./settings.js";
33
33
  import { assertSharedContracts } from "./shared-contracts.js";
34
+ import { skillSourceOf } from "./skill-command.js";
35
+ import { refreshSkills, skipsSkillRefresh } from "./skill-refresh.js";
34
36
  import { commandAt, failureSteps, lineOf, withLines } from "./steps.js";
35
37
  // An MCP server builds the tree on every tool call; the contracts of one definition's tree do not change between builds.
36
38
  const contractsChecked = new WeakSet();
@@ -470,6 +472,9 @@ const runOnce = async (argv, definition, options) => {
470
472
  try {
471
473
  await control.race(() => program.parseAsync(rest, { from: "user" }));
472
474
  const code = exit.code ?? (process.exitCode === undefined ? 0 : Number(process.exitCode));
475
+ const refreshed = code === 0 ? refreshFor(definition, program, rest, options) : undefined;
476
+ if (refreshed && !rest.includes("--quiet"))
477
+ streams.diagnostic(refreshed);
473
478
  const hint = code === 0 && rest[0] !== "commands" && rest[0] !== "help" && !rest.includes("--quiet")
474
479
  ? hintFor(definition, rest, options)
475
480
  : undefined;
@@ -555,6 +560,23 @@ const failingNoticeFor = (app, env, program) => {
555
560
  return undefined;
556
561
  }
557
562
  };
563
+ const NO_REFRESH_COMMANDS = ["skill", "complete", "commands", "help"];
564
+ /** Not while `skill` runs: right after an update, `skill status` must report the copies as they are. */
565
+ const refreshFor = ({ app }, program, argv, options) => {
566
+ const env = options.env ?? process.env;
567
+ try {
568
+ if (NO_REFRESH_COMMANDS.includes(argv[0] ?? "") || skipsSkillRefresh(app, env))
569
+ return undefined;
570
+ const skill = skillSourceOf(program, app.appName);
571
+ if (!skill)
572
+ return undefined;
573
+ const state = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state;
574
+ return refreshSkills({ app, skill, env, markerPath: join(state, "skill-refresh.json") });
575
+ }
576
+ catch {
577
+ return undefined;
578
+ }
579
+ };
558
580
  /** The update notice's state file, so both daily lines share one file per CLI. */
559
581
  const hintFor = ({ app, configuration }, argv, options) => {
560
582
  const env = options.env ?? process.env;
@@ -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">, {}>;