@wirecat/cli-core 0.382.0 → 0.384.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 (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/attachments/documents.d.ts +3 -0
  3. package/dist/attachments/documents.js +17 -0
  4. package/dist/cli/bot/moderation.js +4 -3
  5. package/dist/cli/bot/store.js +5 -4
  6. package/dist/cli/list-query.d.ts +11 -1
  7. package/dist/cli/list-query.js +11 -1
  8. package/dist/cli/meeting-batch.js +2 -1
  9. package/dist/cli/messenger/admin-moderation-command.js +4 -3
  10. package/dist/cli/messenger/archive-commands.js +5 -3
  11. package/dist/cli/messenger/backfill-command.js +9 -4
  12. package/dist/cli/messenger/catch-up-answers.d.ts +5 -2
  13. package/dist/cli/messenger/catch-up-answers.js +11 -6
  14. package/dist/cli/messenger/chat-lists.js +11 -6
  15. package/dist/cli/messenger/chats-stats-command.js +7 -7
  16. package/dist/cli/messenger/contacts-command.js +4 -3
  17. package/dist/cli/messenger/conversation-answers.d.ts +2 -2
  18. package/dist/cli/messenger/conversation-answers.js +5 -2
  19. package/dist/cli/messenger/conversations-command.js +7 -3
  20. package/dist/cli/messenger/inbox.js +2 -2
  21. package/dist/cli/messenger/replies-command.js +4 -3
  22. package/dist/cli/messenger/review.js +2 -1
  23. package/dist/cli/messenger/stats-answers.d.ts +1 -1
  24. package/dist/cli/messenger/stats-answers.js +4 -3
  25. package/dist/cli/runs/search.d.ts +7 -0
  26. package/dist/cli/runs/search.js +11 -3
  27. package/dist/documents/blocks.d.ts +45 -0
  28. package/dist/documents/blocks.js +48 -0
  29. package/dist/documents/index.d.ts +5 -0
  30. package/dist/documents/index.js +4 -0
  31. package/dist/documents/markdown-blocks.d.ts +3 -0
  32. package/dist/documents/markdown-blocks.js +123 -0
  33. package/dist/documents/push-model.d.ts +89 -0
  34. package/dist/documents/push-model.js +1 -0
  35. package/dist/documents.d.ts +1 -0
  36. package/dist/documents.js +1 -0
  37. package/dist/errors/reasons.js +3 -0
  38. package/dist/meetings/answers.d.ts +4 -2
  39. package/dist/meetings/answers.js +2 -1
  40. package/dist/meetings/cli/index.d.ts +1 -1
  41. package/dist/meetings/cli/index.js +1 -1
  42. package/dist/meetings/cli/search.d.ts +5 -0
  43. package/dist/meetings/cli/search.js +23 -5
  44. package/dist/meetings/mcp/search.d.ts +3 -1
  45. package/dist/meetings/mcp/search.js +5 -4
  46. package/dist/services/admin-statistics.d.ts +3 -0
  47. package/dist/services/admin-statistics.js +10 -7
  48. package/dist/services/documents/block-diff.d.ts +24 -0
  49. package/dist/services/documents/block-diff.js +91 -0
  50. package/dist/services/documents/push.d.ts +60 -0
  51. package/dist/services/documents/push.js +218 -0
  52. package/dist/services/retention.d.ts +7 -0
  53. package/dist/services/retention.js +10 -5
  54. package/package.json +5 -1
@@ -2,6 +2,7 @@ import type { AdminOptions, AdminReport } from "../domain/admin-statistics.js";
2
2
  import type { AdminStoreResult, MessageStore } from "../store/store.js";
3
3
  import type { ServiceDeps } from "./deps.js";
4
4
  import type { SearchQuery } from "./messages.js";
5
+ import { type DateWindow } from "./moment.js";
5
6
  import { type RankingSelection } from "./rankings-selection.js";
6
7
  export type AdminQuery = SearchQuery & {
7
8
  answerers?: string[];
@@ -28,6 +29,8 @@ export interface AdminSelection {
28
29
  }
29
30
  export interface AdminFound extends AdminStoreResult {
30
31
  report: AdminReport;
32
+ /** How the typed join dates were read, for the answer's `meta`. */
33
+ timeMeta?: DateWindow["meta"];
31
34
  detectorVersion: 1;
32
35
  items: (AdminStoreResult["items"][number] & {
33
36
  drilldown: {
@@ -2,7 +2,7 @@ import { CliError, singleLine } from "@wirecat/cli-base";
2
2
  import { parseLocator } from "../domain/locator.js";
3
3
  import { isId, pickPerson } from "../resolve.js";
4
4
  import { prepareLucene, stemExecution } from "./messages-search.js";
5
- import { momentOf } from "./moment.js";
5
+ import { dateWindow } from "./moment.js";
6
6
  import { rankingSelection, readRankingSelection } from "./rankings-selection.js";
7
7
  import { searchRecordOf } from "./search-record.js";
8
8
  function invalid(message) {
@@ -205,6 +205,12 @@ export const adminStatisticsService = (deps) => ({
205
205
  if (pinned &&
206
206
  (query.source || query.ast || query.exact || (query.timezone && query.timezone !== prepared.timezone)))
207
207
  invalid("a saved report keeps its resolved query, accounts and timezone");
208
+ const window = dateWindow({
209
+ ...(query.sinceTime ? { sinceTime: query.sinceTime } : {}),
210
+ ...(query.untilTime ? { untilTime: query.untilTime } : {}),
211
+ zone: prepared.timezone,
212
+ now: cutoff,
213
+ });
208
214
  const options = {
209
215
  report,
210
216
  cutoff,
@@ -212,12 +218,8 @@ export const adminStatisticsService = (deps) => ({
212
218
  ? pinned.options.olderThan
213
219
  : duration(query.olderThan ?? "24h", "--older-than"),
214
220
  within: query.within === undefined && pinned ? pinned.options.within : duration(query.within ?? "7d", "--within"),
215
- joinSince: query.sinceTime
216
- ? momentOf(query.sinceTime, "--since-time", cutoff, { zone: prepared.timezone })
217
- : (pinned?.options.joinSince ?? cutoff - 30 * 86_400_000),
218
- joinUntil: query.untilTime
219
- ? momentOf(query.untilTime, "--until-time", cutoff, { zone: prepared.timezone, end: true })
220
- : (pinned?.options.joinUntil ?? cutoff),
221
+ joinSince: window.since ?? pinned?.options.joinSince ?? cutoff - 30 * 86_400_000,
222
+ joinUntil: window.until ?? pinned?.options.joinUntil ?? cutoff,
221
223
  minViews: count(query.minViews ?? pinned?.options.minViews ?? 1, "--min-views"),
222
224
  maxReplies: count(query.maxReplies ?? pinned?.options.maxReplies ?? 0, "--max-replies"),
223
225
  answerers: query.answerers === undefined && pinned
@@ -252,6 +254,7 @@ export const adminStatisticsService = (deps) => ({
252
254
  return {
253
255
  ...result,
254
256
  report,
257
+ timeMeta: window.meta,
255
258
  detectorVersion: 1,
256
259
  items: result.items.map((original) => {
257
260
  const identityKnown = known.has(JSON.stringify([original.account.provider, original.account.account, original.id]));
@@ -0,0 +1,24 @@
1
+ import { type Block } from "../../documents/blocks.js";
2
+ /** `base[from, to)` is replaced by `blocks`; an empty range is an insert, an empty `blocks` a delete. */
3
+ export interface BlockHunk {
4
+ from: number;
5
+ to: number;
6
+ blocks: Block[];
7
+ }
8
+ /** The edits that turn `base` into `next`, leaving every unchanged block in place. */
9
+ export declare const diffBlocks: (base: Block[], next: Block[]) => BlockHunk[];
10
+ export type Reconciled = {
11
+ status: "clean";
12
+ blocks: Block[];
13
+ ticksKept: number;
14
+ } | {
15
+ status: "conflict";
16
+ };
17
+ /**
18
+ * What a push may write. `recorded` is what the last push wrote and read back, `current` the remote
19
+ * now, `local` the file now and `lastLocal` the file as the last push read it. Any remote change other
20
+ * than a checklist tick is a conflict, so the push never overwrites someone's edit. A tick follows the
21
+ * side that changed it: the local file when its tick differs from `lastLocal`, the remote otherwise —
22
+ * judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
23
+ */
24
+ export declare const reconcileBlocks: (recorded: Block[], current: Block[], local: Block[], lastLocal: Block[]) => Reconciled;
@@ -0,0 +1,91 @@
1
+ import { blockKey, blockShape } from "../../documents/blocks.js";
2
+ const MAX_CELLS = 4_000_000;
3
+ /** Longest common subsequence: index pairs [inA, inB] of blocks with equal keys, in order. */
4
+ const matches = (a, b, keyOf) => {
5
+ const ka = a.map(keyOf);
6
+ const kb = b.map(keyOf);
7
+ let start = 0;
8
+ while (start < ka.length && start < kb.length && ka[start] === kb[start])
9
+ start++;
10
+ let endA = ka.length;
11
+ let endB = kb.length;
12
+ while (endA > start && endB > start && ka[endA - 1] === kb[endB - 1]) {
13
+ endA--;
14
+ endB--;
15
+ }
16
+ const rows = endA - start;
17
+ const cols = endB - start;
18
+ const pairs = [];
19
+ for (let i = 0; i < start; i++)
20
+ pairs.push([i, i]);
21
+ if (rows * cols > MAX_CELLS) {
22
+ // Too large to align: the changed middle is rewritten as one block of edits.
23
+ }
24
+ else if (rows && cols) {
25
+ const table = new Uint32Array((rows + 1) * (cols + 1));
26
+ for (let i = rows - 1; i >= 0; i--)
27
+ for (let j = cols - 1; j >= 0; j--)
28
+ table[i * (cols + 1) + j] =
29
+ ka[start + i] === kb[start + j]
30
+ ? table[(i + 1) * (cols + 1) + j + 1] + 1
31
+ : Math.max(table[(i + 1) * (cols + 1) + j], table[i * (cols + 1) + j + 1]);
32
+ let i = 0;
33
+ let j = 0;
34
+ while (i < rows && j < cols) {
35
+ if (ka[start + i] === kb[start + j]) {
36
+ pairs.push([start + i, start + j]);
37
+ i++;
38
+ j++;
39
+ }
40
+ else if (table[(i + 1) * (cols + 1) + j] >= table[i * (cols + 1) + j + 1])
41
+ i++;
42
+ else
43
+ j++;
44
+ }
45
+ }
46
+ for (let offset = 0; offset < ka.length - endA; offset++)
47
+ pairs.push([endA + offset, endB + offset]);
48
+ return pairs;
49
+ };
50
+ /** The edits that turn `base` into `next`, leaving every unchanged block in place. */
51
+ export const diffBlocks = (base, next) => {
52
+ const hunks = [];
53
+ let a = 0;
54
+ let b = 0;
55
+ for (const [i, j] of [...matches(base, next, blockKey), [base.length, next.length]]) {
56
+ if (i > a || j > b)
57
+ hunks.push({ from: a, to: i, blocks: next.slice(b, j) });
58
+ a = i + 1;
59
+ b = j + 1;
60
+ }
61
+ return hunks;
62
+ };
63
+ const isCheck = (block) => block?.kind === "item" && block.list === "check";
64
+ /**
65
+ * What a push may write. `recorded` is what the last push wrote and read back, `current` the remote
66
+ * now, `local` the file now and `lastLocal` the file as the last push read it. Any remote change other
67
+ * than a checklist tick is a conflict, so the push never overwrites someone's edit. A tick follows the
68
+ * side that changed it: the local file when its tick differs from `lastLocal`, the remote otherwise —
69
+ * judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
70
+ */
71
+ export const reconcileBlocks = (recorded, current, local, lastLocal) => {
72
+ const sameShape = recorded.length === current.length &&
73
+ recorded.every((block, index) => blockShape(block) === blockShape(current[index]));
74
+ if (!sameShape)
75
+ return { status: "conflict" };
76
+ const before = new Map(matches(local, lastLocal, blockShape));
77
+ const blocks = local.map((block) => ({ ...block }));
78
+ let ticksKept = 0;
79
+ for (const [i, j] of matches(current, local, blockShape)) {
80
+ const remote = current[i];
81
+ const mine = blocks[j];
82
+ const last = lastLocal[before.get(j) ?? -1];
83
+ if (!isCheck(remote) || !isCheck(mine) || !isCheck(last))
84
+ continue;
85
+ if (Boolean(mine.checked) === Boolean(last.checked) && Boolean(remote.checked) !== Boolean(mine.checked)) {
86
+ mine.checked = Boolean(remote.checked);
87
+ ticksKept++;
88
+ }
89
+ }
90
+ return { status: "clean", blocks, ticksKept };
91
+ };
@@ -0,0 +1,60 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import type { DocumentTarget, LocalDocument, PushPlan, PushState, PushStatePort } from "../../documents/push-model.js";
3
+ export interface PushOptions<S> {
4
+ /** Absolute path of the local folder being mirrored. */
5
+ root: string;
6
+ /** Remote folder id the mirror writes into. */
7
+ folder: string;
8
+ target: DocumentTarget<S>;
9
+ state: PushStatePort<S>;
10
+ principal: string;
11
+ force?: boolean;
12
+ dryRun?: boolean;
13
+ /** Runs one remote write behind the provider's permission checks and write journal. */
14
+ guard<T>(attempt: {
15
+ action: "create" | "update" | "folder";
16
+ path: string;
17
+ operationId: string;
18
+ }, act: () => Promise<T>): Promise<T>;
19
+ operationId(): string;
20
+ errorOf(error: unknown): CliError;
21
+ assertHeld(): void;
22
+ signal?: AbortSignal;
23
+ maxFiles?: number;
24
+ now?(): number;
25
+ }
26
+ export interface PushResult {
27
+ created: string[];
28
+ updated: string[];
29
+ unchanged: number;
30
+ conflicts: {
31
+ path: string;
32
+ url: string;
33
+ reason: string;
34
+ }[];
35
+ missing: string[];
36
+ orphans: {
37
+ path: string;
38
+ url: string;
39
+ }[];
40
+ errors: {
41
+ path: string;
42
+ code: string;
43
+ message: string;
44
+ }[];
45
+ unsupported: number;
46
+ plan?: {
47
+ create: string[];
48
+ update: string[];
49
+ orphans: string[];
50
+ };
51
+ stopped?: true;
52
+ }
53
+ /** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
54
+ export declare const scanLocal: (root: string, kindOf: (path: string) => string | undefined, maxFiles?: number) => Promise<{
55
+ files: LocalDocument[];
56
+ unsupported: number;
57
+ }>;
58
+ /** What a push would do, from the local files and the last push alone: no remote read. */
59
+ export declare const planPush: <S>(files: LocalDocument[], state: PushState<S>) => PushPlan;
60
+ export declare const pushDocuments: <S>(options: PushOptions<S>) => Promise<PushResult>;
@@ -0,0 +1,218 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { readdir } from "node:fs/promises";
4
+ import { join, posix } from "node:path";
5
+ import { CliError, isCliError } from "@wirecat/cli-base";
6
+ const SKIPPED = /^(\.|node_modules$|~\$)|(~|\.swp|\.tmp)$/;
7
+ const hashFile = (path) => new Promise((done, fail) => {
8
+ const hash = createHash("sha256");
9
+ createReadStream(path)
10
+ .on("data", (chunk) => hash.update(chunk))
11
+ .on("error", fail)
12
+ .on("end", () => done(hash.digest("hex")));
13
+ });
14
+ /** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
15
+ export const scanLocal = async (root, kindOf, maxFiles = 10_000) => {
16
+ const files = [];
17
+ let unsupported = 0;
18
+ const walk = async (relative) => {
19
+ const entries = await readdir(join(root, relative), { withFileTypes: true });
20
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
21
+ if (SKIPPED.test(entry.name) || entry.isSymbolicLink())
22
+ continue;
23
+ const path = relative ? posix.join(relative, entry.name) : entry.name;
24
+ if (entry.isDirectory())
25
+ await walk(path);
26
+ else if (entry.isFile()) {
27
+ const kind = kindOf(path);
28
+ if (!kind) {
29
+ unsupported++;
30
+ continue;
31
+ }
32
+ if (files.length >= maxFiles)
33
+ throw new CliError("validation_error", `the folder holds more than ${maxFiles} files to push`, {
34
+ reason: "push.too-many-files",
35
+ });
36
+ const absolute = join(root, path);
37
+ files.push({ path, absolute, kind, hash: await hashFile(absolute) });
38
+ }
39
+ }
40
+ };
41
+ try {
42
+ await walk("");
43
+ }
44
+ catch (error) {
45
+ if (isCliError(error))
46
+ throw error;
47
+ throw new CliError("validation_error", "the folder to push could not be read", { reason: "push.unreadable" });
48
+ }
49
+ return { files, unsupported };
50
+ };
51
+ /** What a push would do, from the local files and the last push alone: no remote read. */
52
+ export const planPush = (files, state) => {
53
+ const plan = { create: [], update: [], unchanged: [], orphans: [] };
54
+ const seen = new Set();
55
+ for (const file of files) {
56
+ seen.add(file.path);
57
+ const entry = state.entries[file.path];
58
+ if (!entry)
59
+ plan.create.push(file);
60
+ else if (entry.hash !== file.hash)
61
+ plan.update.push(file);
62
+ else
63
+ plan.unchanged.push(file);
64
+ }
65
+ plan.orphans = Object.keys(state.entries).filter((path) => !seen.has(path));
66
+ return plan;
67
+ };
68
+ const originOf = (principal, root, folder, path) => createHash("sha256")
69
+ .update(JSON.stringify([principal, root, folder, path]))
70
+ .digest("hex")
71
+ .slice(0, 32);
72
+ export const pushDocuments = async (options) => {
73
+ const { root, folder, target, principal, force = false, signal } = options;
74
+ const now = options.now ?? Date.now;
75
+ if (signal?.aborted)
76
+ throw new CliError("cancelled", "push was cancelled");
77
+ if ((await target.identity()) !== principal)
78
+ throw new CliError("authentication_error", "signed-in account does not match this profile", {
79
+ reason: "profile.wrong-account",
80
+ });
81
+ const state = options.state.read();
82
+ if (state.principal !== principal || state.root !== root || state.target !== folder)
83
+ throw new CliError("configuration_error", "push state belongs to another account or mirror");
84
+ const save = () => options.state.save(state);
85
+ if (!options.dryRun)
86
+ for (const [path, pending] of Object.entries(state.pending)) {
87
+ const found = await target.findByOrigin(pending.origin);
88
+ // An empty hash makes this pass update the found file from the local one.
89
+ if (found)
90
+ state.entries[path] = { ...found, kind: target.kindOf(path) ?? "", hash: "", pushedAt: "" };
91
+ delete state.pending[path];
92
+ save();
93
+ }
94
+ const { files, unsupported } = await scanLocal(root, (path) => target.kindOf(path), options.maxFiles);
95
+ const plan = planPush(files, state);
96
+ const result = {
97
+ created: [],
98
+ updated: [],
99
+ unchanged: plan.unchanged.length,
100
+ conflicts: [],
101
+ missing: [],
102
+ orphans: plan.orphans.map((path) => ({ path, url: state.entries[path]?.url ?? "" })),
103
+ errors: [],
104
+ unsupported,
105
+ };
106
+ if (options.dryRun) {
107
+ result.plan = {
108
+ create: plan.create.map((file) => file.path),
109
+ update: plan.update.map((file) => file.path),
110
+ orphans: plan.orphans,
111
+ };
112
+ return result;
113
+ }
114
+ const folderOf = async (path) => {
115
+ const directory = posix.dirname(path);
116
+ if (directory === ".")
117
+ return folder;
118
+ const known = state.folders[directory];
119
+ if (known)
120
+ return known;
121
+ const parent = await folderOf(directory);
122
+ const operationId = options.operationId();
123
+ options.assertHeld();
124
+ const id = await options.guard({ action: "folder", path: directory, operationId }, () => target.ensureFolder({
125
+ parent,
126
+ name: posix.basename(directory),
127
+ origin: originOf(principal, root, folder, `${directory}/`),
128
+ }));
129
+ state.folders[directory] = id;
130
+ save();
131
+ return id;
132
+ };
133
+ const create = async (file) => {
134
+ const parent = await folderOf(file.path);
135
+ const operationId = options.operationId();
136
+ const origin = originOf(principal, root, folder, file.path);
137
+ state.pending[file.path] = { origin, operationId };
138
+ save();
139
+ options.assertHeld();
140
+ const pushed = await options.guard({ action: "create", path: file.path, operationId }, () => target.create({
141
+ file,
142
+ name: posix.basename(file.path),
143
+ parent,
144
+ origin,
145
+ operationId,
146
+ ...(signal ? { signal } : {}),
147
+ }));
148
+ state.entries[file.path] = { ...pushed, kind: file.kind, hash: file.hash, pushedAt: new Date(now()).toISOString() };
149
+ delete state.pending[file.path];
150
+ save();
151
+ result.created.push(file.path);
152
+ };
153
+ const work = [
154
+ ...plan.create.map((file) => ({ file, create: true })),
155
+ ...plan.update.map((file) => ({ file, create: false })),
156
+ ];
157
+ for (const job of work.sort((a, b) => a.file.path.localeCompare(b.file.path))) {
158
+ if (signal?.aborted) {
159
+ result.stopped = true;
160
+ break;
161
+ }
162
+ const { file } = job;
163
+ try {
164
+ const entry = state.entries[file.path];
165
+ if (job.create || !entry) {
166
+ await create(file);
167
+ continue;
168
+ }
169
+ const prepared = await target.prepare({
170
+ id: entry.id,
171
+ file,
172
+ recorded: entry.snapshot,
173
+ force,
174
+ ...(signal ? { signal } : {}),
175
+ });
176
+ if ("conflict" in prepared) {
177
+ result.conflicts.push({ path: file.path, url: entry.url, reason: prepared.conflict });
178
+ continue;
179
+ }
180
+ if ("missing" in prepared) {
181
+ if (!force) {
182
+ result.missing.push(file.path);
183
+ continue;
184
+ }
185
+ delete state.entries[file.path];
186
+ await create(file);
187
+ continue;
188
+ }
189
+ let pushed;
190
+ if ("unchanged" in prepared) {
191
+ pushed = prepared.unchanged;
192
+ result.unchanged++;
193
+ }
194
+ else {
195
+ const operationId = options.operationId();
196
+ options.assertHeld();
197
+ pushed = await options.guard({ action: "update", path: file.path, operationId }, () => prepared.write());
198
+ result.updated.push(file.path);
199
+ }
200
+ state.entries[file.path] = {
201
+ ...pushed,
202
+ kind: file.kind,
203
+ hash: file.hash,
204
+ pushedAt: new Date(now()).toISOString(),
205
+ };
206
+ save();
207
+ }
208
+ catch (error) {
209
+ const failure = options.errorOf(error);
210
+ if (failure.code === "cancelled") {
211
+ result.stopped = true;
212
+ break;
213
+ }
214
+ result.errors.push({ path: file.path, code: failure.code, message: failure.message });
215
+ }
216
+ }
217
+ return result;
218
+ };
@@ -75,6 +75,13 @@ export declare const retentionService: (deps: ServiceDeps) => {
75
75
  }[];
76
76
  account: import("../store/types.js").AccountKey;
77
77
  report: "retention";
78
+ timeMeta: {
79
+ timezone?: string;
80
+ window?: {
81
+ since?: string;
82
+ until?: string;
83
+ };
84
+ };
78
85
  }>;
79
86
  evidence: (target: "messages" | "contacts", reference: string, selection: unknown, query: {
80
87
  component: string;
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { timezoneOf } from "@wirecat/cli-base/query";
3
3
  import { storedChatId } from "./message-resolution.js";
4
- import { momentOf } from "./moment.js";
4
+ import { dateWindow } from "./moment.js";
5
5
  export const observationDuration = (text, flag) => {
6
6
  const match = /^(\d+(?:\.\d+)?)([smhdw])$/.exec(text);
7
7
  const units = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
@@ -14,11 +14,15 @@ export const retentionService = (deps) => ({
14
14
  report: async (chat, query = {}) => {
15
15
  const cutoff = Date.now();
16
16
  const zone = timezoneOf(query.timezone);
17
+ const window = dateWindow({
18
+ ...(query.sinceTime === undefined ? {} : { sinceTime: query.sinceTime }),
19
+ ...(query.untilTime === undefined ? {} : { untilTime: query.untilTime }),
20
+ zone,
21
+ now: cutoff,
22
+ });
17
23
  const options = {
18
- since: query.sinceTime === undefined
19
- ? cutoff - 90 * 86_400_000
20
- : momentOf(query.sinceTime, "--since-time", cutoff, { zone }),
21
- until: query.untilTime === undefined ? cutoff : momentOf(query.untilTime, "--until-time", cutoff, { zone, end: true }),
24
+ since: window.since ?? cutoff - 90 * 86_400_000,
25
+ until: window.until ?? cutoff,
22
26
  cutoff,
23
27
  checkpoints: (query.checkpoints ?? "1d,7d,30d")
24
28
  .split(",")
@@ -46,6 +50,7 @@ export const retentionService = (deps) => ({
46
50
  })),
47
51
  account,
48
52
  report: "retention",
53
+ timeMeta: window.meta,
49
54
  };
50
55
  },
51
56
  evidence: async (target, reference, selection, query) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.382.0",
3
+ "version": "0.384.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",
@@ -182,6 +182,9 @@
182
182
  "liquidjs": "10.30.0",
183
183
  "lossless-json": "4.3.0",
184
184
  "mammoth": "1.13.0",
185
+ "mdast-util-from-markdown": "2.1.0",
186
+ "mdast-util-gfm": "3.1.0",
187
+ "micromark-extension-gfm": "3.0.0",
185
188
  "ogg-opus-decoder": "1.7.5",
186
189
  "psl": "1.15.0",
187
190
  "qrcode-generator": "2.0.4",
@@ -200,6 +203,7 @@
200
203
  "@cspell/dict-ru_ru": "2.3.3",
201
204
  "@modelcontextprotocol/client": "2.2.0",
202
205
  "@napi-rs/canvas": "1.0.10",
206
+ "@types/mdast": "4.0.4",
203
207
  "@types/node": "^22.20.4",
204
208
  "@types/pngjs": "6.0.5",
205
209
  "@vitest/coverage-v8": "^5.0.2",