@wirecat/cli-core 0.424.0 → 0.426.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 (73) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cli/author.d.ts +5 -6
  3. package/dist/cli/author.js +9 -16
  4. package/dist/cli/commands-command.js +1 -1
  5. package/dist/cli/config-issues.d.ts +20 -2
  6. package/dist/cli/config-issues.js +111 -17
  7. package/dist/cli/context.d.ts +2 -0
  8. package/dist/cli/context.js +17 -6
  9. package/dist/cli/dry-run-guard.d.ts +2 -0
  10. package/dist/cli/dry-run-guard.js +68 -0
  11. package/dist/cli/effects.d.ts +4 -0
  12. package/dist/cli/effects.js +5 -1
  13. package/dist/cli/entry.js +14 -9
  14. package/dist/cli/guide.d.ts +1 -1
  15. package/dist/cli/guide.js +33 -5
  16. package/dist/cli/help/command-help.js +12 -7
  17. package/dist/cli/help/first-screen.js +2 -2
  18. package/dist/cli/index.d.ts +5 -5
  19. package/dist/cli/index.js +5 -4
  20. package/dist/cli/list-query.d.ts +2 -0
  21. package/dist/cli/list-query.js +5 -2
  22. package/dist/cli/meeting-preparation-command.js +2 -2
  23. package/dist/cli/meeting-series-inventory-command.js +2 -2
  24. package/dist/cli/messenger/models-command.js +3 -2
  25. package/dist/cli/messenger/store-maintenance-command.js +15 -5
  26. package/dist/cli/preview.d.ts +44 -5
  27. package/dist/cli/preview.js +26 -8
  28. package/dist/cli/profile.d.ts +7 -0
  29. package/dist/cli/profile.js +19 -0
  30. package/dist/cli/program.js +42 -29
  31. package/dist/cli/runs/search.js +2 -0
  32. package/dist/cli/settings.js +13 -12
  33. package/dist/cli/shared-command-exports.js +3 -1
  34. package/dist/entities/index.d.ts +148 -0
  35. package/dist/entities/recorded-run.d.ts +84 -0
  36. package/dist/entities/recorded-run.js +15 -0
  37. package/dist/entities/store-maintenance.d.ts +64 -0
  38. package/dist/entities/store-maintenance.js +24 -1
  39. package/dist/mcp/agent.js +3 -4
  40. package/dist/mcp/bot/server.js +1 -1
  41. package/dist/mcp/meeting-preparation.js +2 -2
  42. package/dist/mcp/meeting-project-candidates.js +2 -2
  43. package/dist/mcp/meeting-series-inventory.js +2 -2
  44. package/dist/mcp/server.js +1 -1
  45. package/dist/mcp/surface.d.ts +3 -1
  46. package/dist/mcp/surface.js +20 -9
  47. package/dist/mcp/text.d.ts +1 -1
  48. package/dist/mcp/text.js +1 -1
  49. package/dist/mcp/tool.d.ts +1 -1
  50. package/dist/mcp/tool.js +3 -1
  51. package/dist/meetings/mcp/agent-text.d.ts +2 -0
  52. package/dist/meetings/mcp/agent-text.js +2 -0
  53. package/dist/meetings/mcp/index.d.ts +1 -1
  54. package/dist/meetings/mcp/index.js +3 -3
  55. package/dist/meetings/mcp/passage.js +2 -2
  56. package/dist/meetings/mcp/project-evidence.js +2 -2
  57. package/dist/meetings/mcp/search.js +2 -2
  58. package/dist/runs/error-detail.d.ts +42 -0
  59. package/dist/runs/error-detail.js +131 -0
  60. package/dist/runs/events.d.ts +4 -0
  61. package/dist/runs/events.js +2 -0
  62. package/dist/runs/log-attributes.js +2 -0
  63. package/dist/runs/recording.d.ts +5 -2
  64. package/dist/runs/recording.js +20 -19
  65. package/dist/runs/run-context.d.ts +37 -0
  66. package/dist/runs/run-context.js +148 -0
  67. package/dist/runs/run.d.ts +19 -3
  68. package/dist/runs/run.js +1 -0
  69. package/dist/store/exports.d.ts +2 -0
  70. package/dist/store/exports.js +2 -0
  71. package/dist/store/sqlite/store-contents.d.ts +47 -0
  72. package/dist/store/sqlite/store-contents.js +104 -0
  73. package/package.json +1 -1
@@ -0,0 +1,37 @@
1
+ import type { Command } from "commander";
2
+ import type { RunContext } from "./run.js";
3
+ export declare const REDACTED = "[redacted]";
4
+ /** Which coding agent started the process, and its session when the agent names one. Keys as written in run.json. */
5
+ export interface RunAgent {
6
+ kind: string;
7
+ session_id: string | null;
8
+ }
9
+ /**
10
+ * The agent is read from what each one sets in the processes it starts: Claude Code `CLAUDECODE` and
11
+ * `CLAUDE_CODE_SESSION_ID`, Codex `CODEX_THREAD_ID`, OpenCode `OPENCODE` and `OPENCODE_SESSION_ID`, Gemini CLI
12
+ * `GEMINI_CLI`, Cursor `CURSOR_AGENT` — the last two give their commands no session id. `AI_AGENT` (Claude Code,
13
+ * OpenCode) carries a version, `claude-code_2-1-296_agent`, so only the name before it is kept.
14
+ */
15
+ export declare const agentOf: (env: NodeJS.ProcessEnv) => RunAgent | undefined;
16
+ export interface MaskedArgv {
17
+ /** The words after the binary, every value that may be content or a secret replaced by `[redacted]`. */
18
+ argv: string[];
19
+ /** The first word that names no command where one was expected. */
20
+ unknownCommand?: string;
21
+ }
22
+ /**
23
+ * The command line as typed, safe to keep. Command words, flag names and the profile stay; a value stays only when
24
+ * its shape cannot be somebody's words — a number short of a phone's length, a duration, a date, `true`, one of the
25
+ * option's declared choices — and its option or argument is not named for content (`--text`, `<phone>`). Anything
26
+ * else, a positional after `--` or an argument no command declares, is `[redacted]`: a chat is often typed by title.
27
+ */
28
+ export declare const maskArgv: (program: Command, words: readonly string[]) => MaskedArgv;
29
+ /** A run's context from the parsed program: `max messages list` and its masked words, or the word that named nothing. */
30
+ export declare const runContextOf: ({ bin, command, program, argv, env, }: {
31
+ bin: string;
32
+ /** The run's own name, `messages list` or `mcp messages list`; empty when no command was recognised. */
33
+ command: string;
34
+ program?: Command;
35
+ argv?: readonly string[];
36
+ env: NodeJS.ProcessEnv;
37
+ }) => RunContext;
@@ -0,0 +1,148 @@
1
+ import { isContentKey } from "@wirecat/cli-base/standards";
2
+ export const REDACTED = "[redacted]";
3
+ /**
4
+ * The agent is read from what each one sets in the processes it starts: Claude Code `CLAUDECODE` and
5
+ * `CLAUDE_CODE_SESSION_ID`, Codex `CODEX_THREAD_ID`, OpenCode `OPENCODE` and `OPENCODE_SESSION_ID`, Gemini CLI
6
+ * `GEMINI_CLI`, Cursor `CURSOR_AGENT` — the last two give their commands no session id. `AI_AGENT` (Claude Code,
7
+ * OpenCode) carries a version, `claude-code_2-1-296_agent`, so only the name before it is kept.
8
+ */
9
+ export const agentOf = (env) => {
10
+ const named = env.AI_AGENT?.trim().split("_")[0];
11
+ const kind = (named && plainWord(named) ? named.toLowerCase() : undefined) ??
12
+ (env.CLAUDECODE ? "claude-code" : undefined) ??
13
+ (env.CODEX_THREAD_ID || env.CODEX_SESSION_ID || env.CODEX_SANDBOX ? "codex" : undefined) ??
14
+ (env.GEMINI_CLI ? "gemini-cli" : undefined) ??
15
+ (env.OPENCODE ? "opencode" : undefined) ??
16
+ (env.CURSOR_AGENT ? "cursor" : undefined);
17
+ if (!kind)
18
+ return undefined;
19
+ const session = sessionOf(kind, env);
20
+ return { kind, session_id: session && SESSION.test(session) ? session : null };
21
+ };
22
+ const SESSION = /^[A-Za-z0-9._:-]{1,128}$/;
23
+ const sessionOf = (kind, env) => {
24
+ const value = kind === "claude-code"
25
+ ? env.CLAUDE_CODE_SESSION_ID
26
+ : kind === "codex"
27
+ ? env.CODEX_THREAD_ID
28
+ : kind === "opencode"
29
+ ? env.OPENCODE_SESSION_ID
30
+ : undefined;
31
+ return value?.trim() || undefined;
32
+ };
33
+ const plainWord = (word) => /^[A-Za-z][A-Za-z0-9.-]{0,39}$/.test(word);
34
+ /**
35
+ * The command line as typed, safe to keep. Command words, flag names and the profile stay; a value stays only when
36
+ * its shape cannot be somebody's words — a number short of a phone's length, a duration, a date, `true`, one of the
37
+ * option's declared choices — and its option or argument is not named for content (`--text`, `<phone>`). Anything
38
+ * else, a positional after `--` or an argument no command declares, is `[redacted]`: a chat is often typed by title.
39
+ */
40
+ export const maskArgv = (program, words) => {
41
+ const argv = [];
42
+ let unknownCommand;
43
+ let current = program;
44
+ let positional = 0;
45
+ let profileSeen = false;
46
+ let lost = false;
47
+ const optionFor = (flag) => {
48
+ for (let at = current; at; at = at.parent) {
49
+ const found = at.options.find((option) => option.long === flag || option.short === flag);
50
+ if (found)
51
+ return found;
52
+ }
53
+ return undefined;
54
+ };
55
+ for (let at = 0; at < words.length; at++) {
56
+ const word = words[at];
57
+ if (word === "--") {
58
+ argv.push("--");
59
+ for (let after = at + 1; after < words.length; after++)
60
+ argv.push(REDACTED);
61
+ break;
62
+ }
63
+ if (word.startsWith("-") && word.length > 1 && !NUMBER.test(word)) {
64
+ const equals = word.indexOf("=");
65
+ const flag = equals > 0 ? word.slice(0, equals) : word;
66
+ if (!/^--?[A-Za-z0-9][A-Za-z0-9-]*$/.test(flag)) {
67
+ argv.push(REDACTED);
68
+ continue;
69
+ }
70
+ const option = optionFor(flag);
71
+ const name = option?.attributeName() ?? flag.replace(/^-+/, "");
72
+ if (equals > 0) {
73
+ argv.push(`${flag}=${maskedValue(name, word.slice(equals + 1), option?.argChoices)}`);
74
+ continue;
75
+ }
76
+ argv.push(flag);
77
+ if (!option || !(option.required || option.optional))
78
+ continue;
79
+ const takes = (next) => next !== undefined && (option.required || !next.startsWith("-") || NUMBER.test(next));
80
+ if (takes(words[at + 1])) {
81
+ at += 1;
82
+ argv.push(maskedValue(name, words[at], option.argChoices));
83
+ while (option.variadic && words[at + 1] !== undefined && !words[at + 1].startsWith("-")) {
84
+ at += 1;
85
+ argv.push(maskedValue(name, words[at], option.argChoices));
86
+ }
87
+ }
88
+ continue;
89
+ }
90
+ if (!lost && positional === 0) {
91
+ const sub = current.commands.find((one) => one.name() === word || one.aliases().includes(word));
92
+ if (sub) {
93
+ argv.push(word);
94
+ current = sub;
95
+ continue;
96
+ }
97
+ if (current === program && !profileSeen) {
98
+ profileSeen = true;
99
+ argv.push(PROFILE.test(word) ? word : REDACTED);
100
+ continue;
101
+ }
102
+ if (current.commands.length > 0 && current.registeredArguments.length === 0) {
103
+ lost = true;
104
+ unknownCommand = commandLike(word);
105
+ argv.push(unknownCommand);
106
+ continue;
107
+ }
108
+ }
109
+ const declared = lost ? undefined : current.registeredArguments;
110
+ const argument = declared?.[Math.min(positional, declared.length - 1)];
111
+ positional += 1;
112
+ argv.push(argument ? maskedValue(argument.name(), word, argument.argChoices) : REDACTED);
113
+ }
114
+ return { argv, ...(unknownCommand === undefined ? {} : { unknownCommand }) };
115
+ };
116
+ const NUMBER = /^-?\d+(\.\d+)?$/;
117
+ const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
118
+ /** A mistyped command word is kept so the typo can be seen; anything shaped like a sentence or an address is not. */
119
+ const commandLike = (word) => (/^[a-z][a-z0-9-]{0,31}$/i.test(word) ? word : REDACTED);
120
+ const maskedValue = (name, value, choices) => {
121
+ if (isContentKey(name))
122
+ return REDACTED;
123
+ if (name === "profile")
124
+ return PROFILE.test(value) ? value : REDACTED;
125
+ if (name === "json" || name === "fields")
126
+ return /^[a-z0-9_.,]{1,200}$/.test(value) ? value : REDACTED;
127
+ if (choices?.includes(value))
128
+ return value;
129
+ return safeShape(value) ? value : REDACTED;
130
+ };
131
+ // A positive run of eleven or more digits can be a phone number written without its "+"; ids that long are negative
132
+ // (chats and channels) and stay.
133
+ const safeShape = (value) => (NUMBER.test(value) && !/^\d{11,}$/.test(value)) ||
134
+ /^\d+(ms|s|m|h|d|w)$/.test(value) ||
135
+ /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(value) ||
136
+ value === "true" ||
137
+ value === "false";
138
+ /** A run's context from the parsed program: `max messages list` and its masked words, or the word that named nothing. */
139
+ export const runContextOf = ({ bin, command, program, argv, env, }) => {
140
+ const masked = program && argv ? maskArgv(program, argv) : undefined;
141
+ const agent = agentOf(env);
142
+ return {
143
+ cliCommand: command ? `${bin} ${command}` : bin,
144
+ ...(masked ? { argv: masked.argv } : {}),
145
+ ...(masked?.unknownCommand === undefined ? {} : { unknownCommand: masked.unknownCommand }),
146
+ ...(agent ? { agent } : {}),
147
+ };
148
+ };
@@ -1,10 +1,13 @@
1
1
  import { type Logger } from "@wirecat/cli-base";
2
2
  import type { AppIdentity } from "../ports/app.js";
3
+ import type { RunError } from "./error-detail.js";
4
+ import type { RunAgent } from "./run-context.js";
3
5
  export type RunStatus = "success" | "failed" | "partial";
4
6
  /**
5
- * What a run was, and how it ended. **It never says what was read or sent** — a command, a profile
6
- * and a count, and the `errorCode` a script branches on rather than the sentence a person reads,
7
- * because a messenger's refusal can quote the payload back.
7
+ * What a run was, and how it ended. **It never says what was read or sent** — a command, a profile,
8
+ * a count, the arguments with every value that may be content masked, and the `errorCode` a script
9
+ * branches on. The sentence a person reads is kept only in `error`, masked, because a messenger's
10
+ * refusal can quote the payload back.
8
11
  */
9
12
  export interface RunMetadata {
10
13
  runId: string;
@@ -44,7 +47,19 @@ export interface RunMetadata {
44
47
  codes?: string[];
45
48
  /** The caller's trace: the MCP client's, or the schedule's or job's that started it. */
46
49
  traceId?: string;
50
+ /** The whole command with the program's name, `max messages list`; `command` stays the run's short name. */
51
+ cliCommand?: string;
52
+ /** The words after the program's name, every value that may be content or a secret `[redacted]` (`maskArgv`). */
53
+ argv?: string[];
54
+ /** The word that named no command, when that is why the run failed. */
55
+ unknownCommand?: string;
56
+ /** The coding agent that started it, when one did. */
57
+ agent?: RunAgent;
58
+ /** How a failed run failed (`errorDetailOf`). */
59
+ error?: RunError;
47
60
  }
61
+ /** Who started a run and with what, known before it starts. */
62
+ export type RunContext = Pick<RunMetadata, "cliCommand" | "argv" | "unknownCommand" | "agent">;
48
63
  export interface StartRunOptions {
49
64
  runsDir: string;
50
65
  command: string;
@@ -60,6 +75,7 @@ export interface StartRunOptions {
60
75
  startedAt?: Date;
61
76
  /** The caller's trace, written on every record as `trace_id`. */
62
77
  traceId?: string;
78
+ context?: RunContext;
63
79
  /** The log file could not be opened or written; the run goes on unrecorded from here. */
64
80
  onError?: (error: Error) => void;
65
81
  }
package/dist/runs/run.js CHANGED
@@ -74,6 +74,7 @@ export const startRun = (options) => {
74
74
  platform: process.platform,
75
75
  arch: process.arch,
76
76
  ...(options.traceId === undefined ? {} : { traceId: options.traceId }),
77
+ ...options.context,
77
78
  };
78
79
  writeRunFile(dir, metadata);
79
80
  let finished = false;
@@ -32,6 +32,7 @@ export { OTHER_SOURCES, type OtherSource, type SourceMention } from "./sqlite/me
32
32
  export { MENTION_QUERY, MENTION_STATUSES, type MentionDecision, type MentionStatus, type MentionsRebuild, type MentionsState, type StoredMention, type StoredPlace, } from "./sqlite/mentions.js";
33
33
  export { drainNoteIndex, noteIndexState, resetNoteIndex } from "./sqlite/note-index.js";
34
34
  export type { Link as ThingLink } from "./sqlite/notes.js";
35
+ export { openSqlite } from "./sqlite/open.js";
35
36
  export type { PersonPreparationRead, PreparationFact, PreparationNote, PreparationTask, } from "./sqlite/person-preparation.js";
36
37
  export { savedPhoneCountries, saveStorePhoneCountries } from "./sqlite/phone-countries.js";
37
38
  export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people.js";
@@ -46,6 +47,7 @@ export { fillSearchIndex, resetSearchIndex, searchIndexState } from "./sqlite/se
46
47
  export type { SearchCommand, SearchRecord, StoredSearch } from "./sqlite/searches.js";
47
48
  export { type StemsState, savedStemmers, stemmerCache, stemmersOrigin, stemsState, } from "./sqlite/stems.js";
48
49
  export { fillStems, resetStems } from "./sqlite/stems-fill.js";
50
+ export { rowCounts, storeContents } from "./sqlite/store-contents.js";
49
51
  export type { StoredTag, TagFilter, TagTarget, TaskTagTarget } from "./sqlite/tags.js";
50
52
  export { savedTaskCategories } from "./sqlite/task-category-settings.js";
51
53
  export { DEFAULT_AGENT_CLOSE, savedAgentClose } from "./sqlite/task-close.js";
@@ -11,12 +11,14 @@ export { involvementStoreOver } from "./sqlite/involvements.js";
11
11
  export { OTHER_SOURCES } from "./sqlite/mention-sources.js";
12
12
  export { MENTION_QUERY, MENTION_STATUSES, } from "./sqlite/mentions.js";
13
13
  export { drainNoteIndex, noteIndexState, resetNoteIndex } from "./sqlite/note-index.js";
14
+ export { openSqlite } from "./sqlite/open.js";
14
15
  export { savedPhoneCountries, saveStorePhoneCountries } from "./sqlite/phone-countries.js";
15
16
  export { queryFilter } from "./sqlite/query-filter.js";
16
17
  export { AGENDA_COLUMNS } from "./sqlite/reads.js";
17
18
  export { fillSearchIndex, resetSearchIndex, searchIndexState } from "./sqlite/search-index.js";
18
19
  export { savedStemmers, stemmerCache, stemmersOrigin, stemsState, } from "./sqlite/stems.js";
19
20
  export { fillStems, resetStems } from "./sqlite/stems-fill.js";
21
+ export { rowCounts, storeContents } from "./sqlite/store-contents.js";
20
22
  export { savedTaskCategories } from "./sqlite/task-category-settings.js";
21
23
  export { DEFAULT_AGENT_CLOSE, savedAgentClose } from "./sqlite/task-close.js";
22
24
  export { DEFAULT_FIRST_PASS_PER_CHAT, firstPassPerChatOf, savedFirstPassPerChat } from "./sqlite/task-first-pass.js";
@@ -0,0 +1,47 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /** The tables `store info` counts; a file from before a table's migration simply lacks it. */
3
+ export declare const COUNTED_TABLES: readonly ["accounts", "chats", "messages", "emails", "documents", "meetings", "events", "attachments", "identities"];
4
+ export declare const hasTable: (database: CacheDatabase, table: string) => boolean;
5
+ export declare const rowCounts: (database: CacheDatabase) => Partial<Record<(typeof COUNTED_TABLES)[number], number>>;
6
+ /**
7
+ * What the store holds, grouped, over every account in it. Plain `count(*) … GROUP BY` on indexed
8
+ * columns only: `dbstat` would give sizes per table but reads every page of the file.
9
+ */
10
+ export declare const storeContents: (database: CacheDatabase) => {
11
+ byProvider: {
12
+ accounts: number;
13
+ provider: string;
14
+ chats: number;
15
+ documents: number;
16
+ emails: number;
17
+ meetings: number;
18
+ messages: number;
19
+ }[];
20
+ byChatKind: {
21
+ provider: string;
22
+ kind: string;
23
+ chats: number;
24
+ messages: number;
25
+ }[];
26
+ byAccount: {
27
+ provider: string;
28
+ accountId: number;
29
+ name: string | null;
30
+ chats: number;
31
+ documents: number;
32
+ emails: number;
33
+ meetings: number;
34
+ messages: number;
35
+ }[];
36
+ byYear: {
37
+ provider: string;
38
+ year: number;
39
+ messages: number;
40
+ }[];
41
+ byAttachmentKind: {
42
+ type: string;
43
+ kind: string;
44
+ count: number;
45
+ bytes: number;
46
+ }[];
47
+ } | undefined;
@@ -0,0 +1,104 @@
1
+ /** The tables `store info` counts; a file from before a table's migration simply lacks it. */
2
+ export const COUNTED_TABLES = [
3
+ "accounts",
4
+ "chats",
5
+ "messages",
6
+ "emails",
7
+ "documents",
8
+ "meetings",
9
+ "events",
10
+ "attachments",
11
+ "identities",
12
+ ];
13
+ /** Tables whose rows belong to one account, so they can be counted per account and per provider. */
14
+ const PER_ACCOUNT = ["chats", "messages", "emails", "documents", "meetings"];
15
+ export const hasTable = (database, table) => database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
16
+ export const rowCounts = (database) => Object.fromEntries(COUNTED_TABLES.filter((table) => hasTable(database, table)).map((table) => [
17
+ table,
18
+ Number(database.prepare(`SELECT count(*) AS n FROM ${table}`).get()?.n ?? 0),
19
+ ]));
20
+ const zero = () => ({ chats: 0, messages: 0, emails: 0, documents: 0, meetings: 0 });
21
+ /**
22
+ * What the store holds, grouped, over every account in it. Plain `count(*) … GROUP BY` on indexed
23
+ * columns only: `dbstat` would give sizes per table but reads every page of the file.
24
+ */
25
+ export const storeContents = (database) => {
26
+ if (!hasTable(database, "accounts"))
27
+ return undefined;
28
+ const accounts = database.prepare("SELECT id, provider, name FROM accounts ORDER BY provider, id").all();
29
+ const perAccount = new Map(accounts.map((row) => [Number(row.id), zero()]));
30
+ for (const table of PER_ACCOUNT.filter((one) => hasTable(database, one))) {
31
+ for (const row of database.prepare(`SELECT account_id, count(*) AS n FROM ${table} GROUP BY account_id`).all()) {
32
+ const counts = perAccount.get(Number(row.account_id));
33
+ if (counts)
34
+ counts[table] = Number(row.n);
35
+ }
36
+ }
37
+ const providerOf = new Map(accounts.map((row) => [Number(row.id), String(row.provider)]));
38
+ const byProvider = new Map();
39
+ for (const [account, counts] of perAccount) {
40
+ const provider = providerOf.get(account);
41
+ const into = byProvider.get(provider) ?? { accounts: 0, ...zero() };
42
+ into.accounts++;
43
+ for (const table of PER_ACCOUNT)
44
+ into[table] += counts[table];
45
+ byProvider.set(provider, into);
46
+ }
47
+ const byYear = new Map();
48
+ if (hasTable(database, "messages")) {
49
+ // UTC years: the store keeps `sent_at` as epoch milliseconds and the machine's zone is not the owner's.
50
+ const rows = database
51
+ .prepare(`SELECT account_id, CAST(strftime('%Y', sent_at / 1000, 'unixepoch') AS INTEGER) AS year, count(*) AS n
52
+ FROM messages GROUP BY account_id, year`)
53
+ .all();
54
+ for (const row of rows) {
55
+ const provider = providerOf.get(Number(row.account_id)) ?? "unknown";
56
+ const key = `${provider}\u0000${row.year}`;
57
+ const into = byYear.get(key) ?? { provider, year: Number(row.year), messages: 0 };
58
+ into.messages += Number(row.n);
59
+ byYear.set(key, into);
60
+ }
61
+ }
62
+ const byChatKind = hasTable(database, "chats")
63
+ ? database
64
+ .prepare(`SELECT a.provider, c.kind, count(*) AS chats, coalesce(sum(m.n), 0) AS messages
65
+ FROM chats c
66
+ JOIN accounts a ON a.id = c.account_id
67
+ LEFT JOIN (SELECT chat_id, count(*) AS n FROM messages GROUP BY chat_id) m ON m.chat_id = c.id
68
+ GROUP BY a.provider, c.kind
69
+ ORDER BY a.provider, c.kind`)
70
+ .all()
71
+ .map((row) => ({
72
+ provider: String(row.provider),
73
+ kind: String(row.kind),
74
+ chats: Number(row.chats),
75
+ messages: Number(row.messages),
76
+ }))
77
+ : [];
78
+ const byAttachmentKind = hasTable(database, "attachments")
79
+ ? database
80
+ .prepare(`SELECT attachable_type, kind, count(*) AS n, coalesce(sum(size), 0) AS bytes
81
+ FROM attachments GROUP BY attachable_type, kind ORDER BY attachable_type, kind`)
82
+ .all()
83
+ .map((row) => ({
84
+ type: String(row.attachable_type),
85
+ kind: String(row.kind),
86
+ count: Number(row.n),
87
+ bytes: Number(row.bytes),
88
+ }))
89
+ : [];
90
+ return {
91
+ byProvider: [...byProvider]
92
+ .sort(([a], [b]) => a.localeCompare(b))
93
+ .map(([provider, counts]) => ({ provider, ...counts })),
94
+ byChatKind,
95
+ byAccount: accounts.map((row) => ({
96
+ provider: String(row.provider),
97
+ accountId: Number(row.id),
98
+ name: row.name === null ? null : String(row.name),
99
+ ...perAccount.get(Number(row.id)),
100
+ })),
101
+ byYear: [...byYear.values()].sort((a, b) => a.provider.localeCompare(b.provider) || a.year - b.year),
102
+ byAttachmentKind,
103
+ };
104
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.424.0",
3
+ "version": "0.426.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",