@wirecat/cli-core 0.562.0 → 0.563.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 +62 -0
- package/dist/cli/config-command.js +18 -1
- package/dist/cli/envelope.js +13 -1
- package/dist/cli/execution.js +1 -0
- package/dist/cli/messenger/reply-stats-command.d.ts +4 -0
- package/dist/cli/messenger/reply-stats-command.js +57 -0
- package/dist/cli/messenger/search-command.js +8 -0
- package/dist/cli/messenger/stats-answers.d.ts +3 -0
- package/dist/cli/messenger/stats-answers.js +18 -0
- package/dist/cli/messenger/stats-command.js +5 -0
- package/dist/cli/output-budget.d.ts +26 -0
- package/dist/cli/output-budget.js +73 -0
- package/dist/cli/program.js +14 -1
- package/dist/cli/settings.d.ts +5 -0
- package/dist/cli/settings.js +88 -5
- package/dist/cli/shared-command-exports.d.ts +8 -0
- package/dist/domain/admin-statistics.d.ts +5 -0
- package/dist/domain/admin-statistics.js +4 -3
- package/dist/entities/index.d.ts +31 -1
- package/dist/entities/index.js +4 -1
- package/dist/entities/reply-stats.d.ts +33 -0
- package/dist/entities/reply-stats.js +19 -0
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +2 -3
- package/dist/mail/attachments.d.ts +15 -2
- package/dist/mail/attachments.js +19 -3
- package/dist/mail/cli/index.d.ts +17 -1
- package/dist/mail/cli/index.js +1 -1
- package/dist/mail/cli/review.d.ts +5 -2
- package/dist/mail/cli/review.js +47 -39
- package/dist/mail/index.d.ts +2 -0
- package/dist/mail/index.js +2 -0
- package/dist/mail/partial.d.ts +3 -1
- package/dist/mail/partial.js +2 -2
- package/dist/mail/retry.d.ts +3 -1
- package/dist/mail/retry.js +3 -3
- package/dist/mail/sync-types.d.ts +3 -0
- package/dist/mail/sync.js +5 -3
- package/dist/mail/whole.d.ts +28 -0
- package/dist/mail/whole.js +69 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/proposal-tasks.js +3 -12
- package/dist/services/proposals/kinds/action.js +3 -2
- package/dist/services/proposals/registry.js +8 -4
- package/dist/services/proposals/types.d.ts +2 -0
- package/dist/services/reply-stats.d.ts +48 -0
- package/dist/services/reply-stats.js +137 -0
- package/dist/store/decisions.d.ts +14 -11
- package/dist/store/decisions.js +9 -2
- package/dist/store/index.d.ts +3 -3
- package/dist/store/index.js +2 -2
- package/dist/store/queries.d.ts +4 -0
- package/dist/store/queries.js +3 -0
- package/dist/store/sqlite/email-locations.d.ts +4 -0
- package/dist/store/sqlite/email-locations.js +26 -11
- package/dist/store/sqlite/lucene-fields.d.ts +2 -0
- package/dist/store/sqlite/lucene-fields.js +3 -1
- package/dist/store/sqlite/mail-findings.js +1 -1
- package/dist/store/sqlite/meeting-findings.js +1 -1
- package/dist/store/sqlite/proposed-actions.d.ts +2 -0
- package/dist/store/sqlite/proposed-actions.js +24 -17
- package/dist/store/sqlite/reply-times.d.ts +57 -0
- package/dist/store/sqlite/reply-times.js +113 -0
- package/dist/store/sqlite/work-tasks.d.ts +12 -2
- package/dist/store/sqlite/work-tasks.js +35 -26
- package/dist/store/store.d.ts +1 -0
- package/package.json +1 -1
- package/parity.json +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,68 @@ 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.563.0 — 03.11.2026
|
|
11
|
+
|
|
12
|
+
### Changed — may break callers
|
|
13
|
+
|
|
14
|
+
- **Mail sync reads attachments up to 10 MB a file by default, and can skip them.** Until now every attachment was
|
|
15
|
+
read, whatever its size; at the next bump a file over 10 MB is no longer read into the index unless the caller
|
|
16
|
+
passes a policy. `MailSyncOptions.attachments` takes a `MailAttachmentPolicy` (`read`, `maxBytes`); left out it is
|
|
17
|
+
`DEFAULT_ATTACHMENT_POLICY`. A file the policy leaves is stored with its name, type and size but no text, with
|
|
18
|
+
`extractionError` `not-read` or `too-large` (`POLICY_SKIPS`), and the run's `attachments` log says the same.
|
|
19
|
+
Partial completion and `retryAttachments` follow it too.
|
|
20
|
+
- **Moving a task to another project moves its subtasks with it.** `tasks.edit(id, { project })` on a task with
|
|
21
|
+
subtasks no longer refuses with `task.has-subtasks`; that reason is gone. Every subtask, all the way down, takes the
|
|
22
|
+
next key in the new project and keeps its parent, and its old key still finds it. The answer names them as
|
|
23
|
+
`movedSubtasks: [{ from, to }]`, old key and new; the field is left out when nothing moved with the task. A subtask
|
|
24
|
+
moved on its own still needs `parent: null` in the same edit (`task.is-subtask`).
|
|
25
|
+
|
|
26
|
+
- **Owner-only proposals refuse an agent on every decide path.** `mayDecide` and `assertMayDecide` now read
|
|
27
|
+
the row's stored `kind`: a `mail_findings` or `meeting_findings` row is the owner's alone, whatever
|
|
28
|
+
`agent_decisions` says. The registry's `get` and `list` rows carry `ownerOnly` from their kind, and the generic
|
|
29
|
+
action kind marks a stored findings row too, so a host checking `assertMayDecide(await registry.get(id), …)` is
|
|
30
|
+
refused. `approveAsTask` and `proposedActions.approve` / `reject` refuse a mail findings batch as they refuse
|
|
31
|
+
meeting and evidence findings (`mail-findings.dedicated-review`), and the generic action kind no longer lists any
|
|
32
|
+
findings batch. Meeting findings were open to another agent with a reason before; now only the owner reviews them.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **An answer over the output cap says what to run instead (WC-OUT-22).** The `output.too-large` failure now names up
|
|
37
|
+
to two reads, both safe to run unasked: the same command line with `--compact`, when the command declares a row and
|
|
38
|
+
was not compact already, and the same line with a `--limit` scaled down to fit. The lines are rebuilt from the
|
|
39
|
+
words the caller typed, never from the answer. The failure also carries `needed_bytes`.
|
|
40
|
+
- **A JSON answer over 256 KiB adds one step to the compact form.** The answer is still printed whole. Nothing is
|
|
41
|
+
added when the answer already names two steps, over MCP, or for a command with no declared row.
|
|
42
|
+
- **`search messages --chat` and `search all --chat` say how to search one chat:** `chat:<chat>` in the query.
|
|
43
|
+
|
|
44
|
+
- **`readEmailWhole` reads one stored email again from its mail server, every attachment of it with no limit.** It
|
|
45
|
+
saves the new text on the email as a sync does, keeping its mailboxes, flags and rule, and answers each attachment's
|
|
46
|
+
bytes for the caller to save. Gmail's email is found by id, another server's at its stored place.
|
|
47
|
+
`store.mail.emailPlace(accountId, externalId)` gives an email's thread and stored places.
|
|
48
|
+
- **`stats messages responses`**: how fast replies come in one-to-one chats, the owner's (`--who me`) or the other
|
|
49
|
+
side's (`--who others`): count, median, p90, mean and four time buckets, in total or `--by day` in `--timezone`.
|
|
50
|
+
Several messages in a row followed by one answer are one reply, timed from the first. Reads by index, so all time
|
|
51
|
+
works on a large store; the query after `--` takes `date:`, `chat:` and `kind:` only.
|
|
52
|
+
- **`stats messages waiting`**: how many chats wait on the owner — a one-to-one chat whose newest message is a
|
|
53
|
+
question, or a group whose newest message mentions or replies to the owner — with the messages since the owner
|
|
54
|
+
last wrote, the oldest of them, and the split by kind. Muted or archived one-to-one chats are left out, as in
|
|
55
|
+
`inbox`.
|
|
56
|
+
- **tg, max, wa, mail, gcal and drive's config file holds every shared setting with its default.** A first run now
|
|
57
|
+
writes all of them into `defaults`, not five; `config set` and `config unset` add the missing ones (but not the one
|
|
58
|
+
just unset); and the new `<cli> config migrate` fills an existing file. Your values, their order and unknown keys
|
|
59
|
+
stay; a file without `defaults` gets that section, and no other section is created. `config show` gives each of
|
|
60
|
+
these settings its `default` and `description`, and no longer creates the file when there is none. `settingsFor`
|
|
61
|
+
returns `declaredSettings` and `migrate(path)`; `extension.settings` declares a CLI's own (cli-base WC-CMD-30).
|
|
62
|
+
Settings with no single default (`timeout_ms`, `color`, `requests_per_minute`, the embedding model) and objects
|
|
63
|
+
(`permissions`, `models`) are left out.
|
|
64
|
+
- **`MailReads.mount`: the steps a mail review hands on name where the host mounted it.** A host that mounts `review`
|
|
65
|
+
and `messages acknowledge` elsewhere passes their paths and the option naming the mailbox — memo passes
|
|
66
|
+
`review: "mail review"`, `conversation: null` (it mounts no thread read) and `mailbox: { option: "source", value }`
|
|
67
|
+
— and `next` names those paths and `--source email:<account>`. `mailReviewSteps(mount)` gives the definitions for
|
|
68
|
+
a host's step checks; a mail CLI leaves it out, and its step lines carry the profile as before.
|
|
69
|
+
|
|
8
70
|
## 0.562.0 — 03.11.2026
|
|
9
71
|
|
|
10
72
|
### Added
|
|
@@ -30,6 +30,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
|
|
|
30
30
|
const bot = this.opts().bot === true;
|
|
31
31
|
const context = baseContext(this, (flags, options) => config.resolveSettings(flags, {
|
|
32
32
|
...options,
|
|
33
|
+
readOnly: true,
|
|
33
34
|
needsProfile: false,
|
|
34
35
|
withIssues: true,
|
|
35
36
|
...(bot ? { kind: "bot" } : {}),
|
|
@@ -48,7 +49,13 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
|
|
|
48
49
|
paths_overridden: overridden,
|
|
49
50
|
settings: [...config.allSettings, "command_timeout_ms"]
|
|
50
51
|
.filter((setting) => bot || setting !== "read_other_bots")
|
|
51
|
-
.map((setting) =>
|
|
52
|
+
.map((setting) => {
|
|
53
|
+
const declared = config.declaredSettings.find(({ key }) => key === `defaults.${setting}`);
|
|
54
|
+
return {
|
|
55
|
+
...sourced(settings, setting),
|
|
56
|
+
...(declared ? { default: declared.default, description: declared.description } : {}),
|
|
57
|
+
};
|
|
58
|
+
}),
|
|
52
59
|
store_settings: await storeSettings(env),
|
|
53
60
|
}));
|
|
54
61
|
for (const issue of [
|
|
@@ -144,9 +151,19 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
|
|
|
144
151
|
}));
|
|
145
152
|
});
|
|
146
153
|
}
|
|
154
|
+
command.addCommand(migrateCommand(app, config));
|
|
147
155
|
command.addCommand(repairCommand(app, config));
|
|
148
156
|
return command;
|
|
149
157
|
};
|
|
158
|
+
const migrateCommand = (app, config) => profileFree(grantsAuthority(annotate(new Command("migrate"), { effects: LOCAL_WRITE, examples: [`${app.command} config migrate`] })))
|
|
159
|
+
.description("write each setting the file lacks at its default, keeping yours")
|
|
160
|
+
.action(function () {
|
|
161
|
+
const context = baseContext(this, (flags, options) => config.resolveSettings(flags, { ...options, needsProfile: false, withIssues: true }));
|
|
162
|
+
const file = context.settings.configPath;
|
|
163
|
+
const added = config.migrate(file);
|
|
164
|
+
renderAnswer(this, context, itemOf({ config_file: file, added }));
|
|
165
|
+
context.renderer.note(added.length > 0 ? `added ${added.join(", ")} to ${file}` : `${file} already has every setting`);
|
|
166
|
+
});
|
|
150
167
|
/**
|
|
151
168
|
* Mends JSON syntax with one reading and keeps every value (NEED-2178): no network, no AI, since the file may hold
|
|
152
169
|
* keys. Anything else is refused with nothing changed.
|
package/dist/cli/envelope.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as v from "valibot";
|
|
|
5
5
|
import { currentRun } from "../runs/scope.js";
|
|
6
6
|
import { compactText, entityRows, rowShapeOf, rowsOfEntity, TEXT_CHARS } from "./compact.js";
|
|
7
7
|
import { environmentOf, setExitCode } from "./context.js";
|
|
8
|
+
import { largeAnswerSteps, SOFT_OUTPUT_BYTES } from "./output-budget.js";
|
|
8
9
|
import { withoutProviderRefs } from "./provider-refs.js";
|
|
9
10
|
import { nextStep, rootOf, runnable, stepLine, withLines, withSafety } from "./steps.js";
|
|
10
11
|
/** Every answer: a list or one thing, with items of `item` — any value where the command declares no entity. */
|
|
@@ -131,6 +132,16 @@ export const sealed = (value) => {
|
|
|
131
132
|
};
|
|
132
133
|
/** The steps a compact answer prints, each rerunning in the compact form the caller chose. */
|
|
133
134
|
const compactSteps = (root, steps) => steps.map((step) => step.kind === "command" ? { ...step, line: `${step.line ?? stepLine(root, step)} ${COMPACT_FLAG}` } : step);
|
|
135
|
+
/** A JSON answer past the soft budget, printed whole, with one more step: the same read in the compact form. */
|
|
136
|
+
const withLargeAnswerStep = (command, body) => {
|
|
137
|
+
if ((body.next?.length ?? 0) >= MAX_NEXT_STEPS)
|
|
138
|
+
return body;
|
|
139
|
+
const added = largeAnswerSteps(command);
|
|
140
|
+
if (added.length === 0 || Buffer.byteLength(JSON.stringify(body)) <= SOFT_OUTPUT_BYTES)
|
|
141
|
+
return body;
|
|
142
|
+
const root = rootOf(command);
|
|
143
|
+
return { ...body, next: [...(body.next ?? []), ...withLines(root, runnable(root, added))] };
|
|
144
|
+
};
|
|
134
145
|
/** An answer as JSON lines: one line per item, then the end line. One thing is a list of one that has no more. */
|
|
135
146
|
const linesOf = (body) => jsonlOf("items" in body ? body : { items: [body.item], page: { mode: "page", limit: 1, has_more: false }, meta: body.meta });
|
|
136
147
|
/** An answer as JSON-lines records, for a writer that streams objects: one per item, then the end record. */
|
|
@@ -151,9 +162,10 @@ export const renderAnswer = (command, { renderer, format, streams, compact, full
|
|
|
151
162
|
const kept = withoutProviderRefs({ ...body, ...(next.length > 0 ? { next } : {}) });
|
|
152
163
|
if (next.length === 0)
|
|
153
164
|
delete kept.next;
|
|
154
|
-
const
|
|
165
|
+
const sealedBody = sealed(kept);
|
|
155
166
|
const environment = environmentOf(command);
|
|
156
167
|
const drawn = compact && !environment.agent;
|
|
168
|
+
const whole = format === "json" && !drawn && !environment.agent ? withLargeAnswerStep(command, sealedBody) : sealedBody;
|
|
157
169
|
if (drawn) {
|
|
158
170
|
const lined = whole.next ? { ...whole, next: compactSteps(root, whole.next) } : whole;
|
|
159
171
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
package/dist/cli/execution.js
CHANGED
|
@@ -116,6 +116,7 @@ export const execution = (streams, { timeoutMs, fields, maxOutputBytes = DEFAULT
|
|
|
116
116
|
reason: "output.too-large",
|
|
117
117
|
max_bytes: maxOutputBytes,
|
|
118
118
|
emitted_bytes: bytes,
|
|
119
|
+
needed_bytes: bytes + size,
|
|
119
120
|
partial_output: bytes > 0,
|
|
120
121
|
});
|
|
121
122
|
bytes += size;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { annotate } from "@wirecat/cli-base/commands";
|
|
2
|
+
import { Command, Option } from "commander";
|
|
3
|
+
import { LOCAL_READ } from "../effects.js";
|
|
4
|
+
import { renderAnswer } from "../envelope.js";
|
|
5
|
+
import { messengerContext } from "./context.js";
|
|
6
|
+
import { refuseJsonl, responsesAnswer, waitingAnswer } from "./stats-answers.js";
|
|
7
|
+
const QUERY = "date:, chat: and kind: terms, AND-ed — date>=30d, date:[60d TO 30d]; none reads all time";
|
|
8
|
+
const GUIDE = "Guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/rankings.md#reply-times-and-chats-waiting-on-you";
|
|
9
|
+
const minutes = (ms) => (ms === null ? "—" : `${Math.round(ms / 60_000)} min`);
|
|
10
|
+
export const replyResponsesCommand = (messenger) => annotate(new Command("responses"), { effects: LOCAL_READ, output: { entity: "response_times" } })
|
|
11
|
+
.summary("how fast replies come in one-to-one chats: median, p90, mean and buckets")
|
|
12
|
+
.description("how long the owner took to answer (--who me), or the other side took to answer the owner (--who others), in " +
|
|
13
|
+
"one-to-one chats — the local store only. Several messages in a row followed by one answer count as one " +
|
|
14
|
+
"reply, timed from the first unanswered message; the day of a row is the day of the reply")
|
|
15
|
+
.argument("[query...]", QUERY)
|
|
16
|
+
.addOption(new Option("--who <side>", "whose replies: the owner's or the other side's").choices(["me", "others"]))
|
|
17
|
+
.addOption(new Option("--by <group>", "one row per calendar day of the reply, or none").choices(["day", "total"]))
|
|
18
|
+
.option("--timezone <zone>", "the IANA timezone for calendar days and date: bounds")
|
|
19
|
+
.option("--source <accounts>", "every account of this messenger held in the store; personal, bots or all")
|
|
20
|
+
.addHelpText("after", GUIDE)
|
|
21
|
+
.action(async function (words) {
|
|
22
|
+
const context = messengerContext(this, messenger);
|
|
23
|
+
refuseJsonl(context.format);
|
|
24
|
+
const { who, by, timezone, source } = this.opts();
|
|
25
|
+
const found = await context.withServices((services) => services.replyStats.responses({
|
|
26
|
+
who: who ?? "me",
|
|
27
|
+
by: by ?? "total",
|
|
28
|
+
...(words.length ? { text: words.join(" ") } : {}),
|
|
29
|
+
...(timezone === undefined ? {} : { timezone }),
|
|
30
|
+
...(source === undefined ? {} : { source }),
|
|
31
|
+
}));
|
|
32
|
+
renderAnswer(this, context, responsesAnswer(found), () => [
|
|
33
|
+
`${found.replies} replies · median ${minutes(found.medianMs)} · p90 ${minutes(found.p90Ms)} · mean ${minutes(found.meanMs)}`,
|
|
34
|
+
...found.rows.map((row) => `${row.key} ${row.replies} replies · median ${minutes(row.medianMs)}`),
|
|
35
|
+
"",
|
|
36
|
+
].join("\n"));
|
|
37
|
+
});
|
|
38
|
+
export const replyWaitingCommand = (messenger) => annotate(new Command("waiting"), { effects: LOCAL_READ, output: { entity: "waiting_chats" } })
|
|
39
|
+
.summary("chats whose newest message asks the owner something")
|
|
40
|
+
.description("how many chats wait on the owner — the local store only: a one-to-one chat whose newest message is a " +
|
|
41
|
+
"question, or a group whose newest message mentions the owner or replies to them. Muted or archived " +
|
|
42
|
+
"one-to-one chats are left out, as inbox leaves them out; date: bounds the newest message's time")
|
|
43
|
+
.argument("[query...]", QUERY)
|
|
44
|
+
.option("--timezone <zone>", "the IANA timezone for date: bounds")
|
|
45
|
+
.option("--source <accounts>", "every account of this messenger held in the store; personal, bots or all")
|
|
46
|
+
.addHelpText("after", GUIDE)
|
|
47
|
+
.action(async function (words) {
|
|
48
|
+
const context = messengerContext(this, messenger);
|
|
49
|
+
refuseJsonl(context.format);
|
|
50
|
+
const { timezone, source } = this.opts();
|
|
51
|
+
const found = await context.withServices((services) => services.replyStats.waiting({
|
|
52
|
+
...(words.length ? { text: words.join(" ") } : {}),
|
|
53
|
+
...(timezone === undefined ? {} : { timezone }),
|
|
54
|
+
...(source === undefined ? {} : { source }),
|
|
55
|
+
}));
|
|
56
|
+
renderAnswer(this, context, waitingAnswer(found), () => `${found.chats} chats wait on you (${found.byKind.private} one-to-one, ${found.byKind.group} groups) · ${found.messages} messages${found.oldestAt ? ` · oldest ${found.oldestAt}` : ""}\n`);
|
|
57
|
+
});
|
|
@@ -5,6 +5,7 @@ import { SAVE_HELP } from "../../meetings/index.js";
|
|
|
5
5
|
import { searchNotesOf } from "../../services/notes-search.js";
|
|
6
6
|
import { LOCAL_READ, REMOTE_READ } from "../effects.js";
|
|
7
7
|
import { renderAnswer } from "../envelope.js";
|
|
8
|
+
import { withOptionHints } from "../option-hints.js";
|
|
8
9
|
import { positiveCount } from "../paging.js";
|
|
9
10
|
import { runsSearchCommand } from "../runs/search-command.js";
|
|
10
11
|
import { commandAt } from "../steps.js";
|
|
@@ -193,5 +194,12 @@ export const searchCommand = (messenger, { topics = false } = {}) => {
|
|
|
193
194
|
if (command)
|
|
194
195
|
annotate(command, { examples: [`${messenger.app.command} personal search ${path} ${args}`] });
|
|
195
196
|
}
|
|
197
|
+
for (const path of ["all", "messages"]) {
|
|
198
|
+
const command = commandAt(search, path);
|
|
199
|
+
if (command)
|
|
200
|
+
withOptionHints(command, {
|
|
201
|
+
"--chat": `search in one chat with chat:<chat> in the query: search ${path} 'chat:<chat> words'`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
196
204
|
return search;
|
|
197
205
|
};
|
|
@@ -8,6 +8,7 @@ import type { AdminEvidenceFound, AdminFound } from "../../services/admin-statis
|
|
|
8
8
|
import type { ChatStats } from "../../services/chat-stats.js";
|
|
9
9
|
import type { MessageStats } from "../../services/messages-search.js";
|
|
10
10
|
import type { RankingFound } from "../../services/rankings.js";
|
|
11
|
+
import type { ReplyTimes, Waiting } from "../../services/reply-stats.js";
|
|
11
12
|
import type { RetentionService } from "../../services/retention.js";
|
|
12
13
|
import type { AccountKey, RankedEvidence } from "../../store/store.js";
|
|
13
14
|
import type { GroupStats } from "../../tasks/index.js";
|
|
@@ -72,4 +73,6 @@ export declare const chatStatsAnswer: (stats: ChatStats, account: AccountKey, me
|
|
|
72
73
|
export declare const MEMBERS_UNRECORDED = "joins and leaves are not recorded for this chat yet, so they are not counted";
|
|
73
74
|
/** `stats tasks show`, CLI and MCP alike. */
|
|
74
75
|
export declare const taskStatsAnswer: (rows: GroupStats[], account: AccountKey) => ItemBody<Of<"task_stats">>;
|
|
76
|
+
export declare const responsesAnswer: (found: ReplyTimes) => ItemBody<Of<"response_times">>;
|
|
77
|
+
export declare const waitingAnswer: (found: Waiting) => ItemBody<Of<"waiting_chats">>;
|
|
75
78
|
export {};
|
|
@@ -420,3 +420,21 @@ export const taskStatsAnswer = (rows, account) => itemOf({
|
|
|
420
420
|
median_close_ms: row.medianCloseMs ?? null,
|
|
421
421
|
})),
|
|
422
422
|
});
|
|
423
|
+
const replyFigures = (one) => ({
|
|
424
|
+
replies: one.replies,
|
|
425
|
+
median_ms: one.medianMs,
|
|
426
|
+
p90_ms: one.p90Ms,
|
|
427
|
+
mean_ms: one.meanMs,
|
|
428
|
+
});
|
|
429
|
+
export const responsesAnswer = (found) => itemOf({
|
|
430
|
+
who: found.who,
|
|
431
|
+
...replyFigures(found),
|
|
432
|
+
buckets: {
|
|
433
|
+
under_5m: found.buckets.under5m,
|
|
434
|
+
under_1h: found.buckets.under1h,
|
|
435
|
+
under_1d: found.buckets.under1d,
|
|
436
|
+
over_1d: found.buckets.over1d,
|
|
437
|
+
},
|
|
438
|
+
rows: found.rows.map((row) => ({ key: row.key, ...replyFigures(row) })),
|
|
439
|
+
});
|
|
440
|
+
export const waitingAnswer = (found) => itemOf({ chats: found.chats, messages: found.messages, oldest_at: found.oldestAt, by_kind: found.byKind });
|
|
@@ -20,6 +20,7 @@ import { countersCommand } from "./counters-command.js";
|
|
|
20
20
|
import { messagesStatsCommand } from "./messages-stats-command.js";
|
|
21
21
|
import { officialStatsCommand } from "./official-stats-command.js";
|
|
22
22
|
import { rankingEvidenceCommand, rankingsTopCommand } from "./rankings-command.js";
|
|
23
|
+
import { replyResponsesCommand, replyWaitingCommand } from "./reply-stats-command.js";
|
|
23
24
|
import { retentionCommand } from "./retention-command.js";
|
|
24
25
|
import { chartAnswer, refuseJsonl } from "./stats-answers.js";
|
|
25
26
|
import { tasksStatsCommand } from "./tasks-command.js";
|
|
@@ -31,6 +32,8 @@ export const statsCommand = (messenger, loadRenderer = chartRenderer) => {
|
|
|
31
32
|
.addCommand(countersCommand(messenger))
|
|
32
33
|
.addCommand(adminStatisticsCommand(messenger, "unanswered"))
|
|
33
34
|
.addCommand(adminStatisticsCommand(messenger, "discussion"))
|
|
35
|
+
.addCommand(replyResponsesCommand(messenger))
|
|
36
|
+
.addCommand(replyWaitingCommand(messenger))
|
|
34
37
|
.addCommand(rankingsTopCommand(messenger, "messages"))
|
|
35
38
|
.addCommand(rankingEvidenceCommand(messenger, "messages")));
|
|
36
39
|
stats.addCommand(new Command("contacts")
|
|
@@ -96,10 +99,12 @@ export const statsCommand = (messenger, loadRenderer = chartRenderer) => {
|
|
|
96
99
|
"messages counters sync": '--chat "Book club" --dry-run',
|
|
97
100
|
"messages counters show": '--chat "Book club" --limit 10',
|
|
98
101
|
"messages discussion": 'chat:"Book club"',
|
|
102
|
+
"messages responses": "--who me --by day --timezone Europe/Madrid -- date>=30d",
|
|
99
103
|
"messages evidence": '"$message" --selection "$selection" --component reactions',
|
|
100
104
|
"messages show": '--chat "Book club" --by day',
|
|
101
105
|
"messages top": 'chat:"Book club" --limit 10',
|
|
102
106
|
"messages unanswered": 'chat:"Project room"',
|
|
107
|
+
"messages waiting": "kind:private",
|
|
103
108
|
"tasks show": '--chat "Project room"',
|
|
104
109
|
};
|
|
105
110
|
for (const [path, args] of Object.entries(examples)) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NextStep } from "@wirecat/cli-base/standards";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
/** About a third of a 200k-token context at ~3 bytes per token (inferred, not measured). */
|
|
4
|
+
export declare const SOFT_OUTPUT_BYTES: number;
|
|
5
|
+
/** The same read in a form that fits the cap: one line per item, then a `--limit` scaled down to fit (WC-OUT-22). */
|
|
6
|
+
export declare const tooLargeSteps: (command: Command, { maxBytes, neededBytes }: {
|
|
7
|
+
maxBytes: number;
|
|
8
|
+
neededBytes: number;
|
|
9
|
+
}) => ({
|
|
10
|
+
kind: "command";
|
|
11
|
+
step: string;
|
|
12
|
+
command: string;
|
|
13
|
+
args: {
|
|
14
|
+
[x: string]: unknown;
|
|
15
|
+
};
|
|
16
|
+
line?: string | undefined;
|
|
17
|
+
why: string;
|
|
18
|
+
safe_to_auto_execute: boolean;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "user_action";
|
|
21
|
+
step: string;
|
|
22
|
+
why: string;
|
|
23
|
+
safe_to_auto_execute: false;
|
|
24
|
+
})[];
|
|
25
|
+
/** The step a large JSON answer adds: the same read, one line per item. */
|
|
26
|
+
export declare const largeAnswerSteps: (command: Command) => NextStep[];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { entityRows, GENERIC_ROW, rowShapeOf } from "./compact.js";
|
|
2
|
+
import { environmentOf } from "./context.js";
|
|
3
|
+
import { rootOf } from "./steps.js";
|
|
4
|
+
/** About a third of a 200k-token context at ~3 bytes per token (inferred, not measured). */
|
|
5
|
+
export const SOFT_OUTPUT_BYTES = 256 * 1024;
|
|
6
|
+
const CARRIED = ["json", "compact", "offline", "timeout", "full", "brief", "maxOutputBytes"];
|
|
7
|
+
const NOT_WITH_COMPACT = new Set(["json", "brief"]);
|
|
8
|
+
const snake = (name) => name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
9
|
+
const typed = (command, key) => command.getOptionValueSource(key) === "cli";
|
|
10
|
+
/**
|
|
11
|
+
* The words the caller typed, as step arguments: never a default, never text an answer carried (WC-OUT-9). A
|
|
12
|
+
* `--no-x` flag and `--json=<fields>` have no form a step can carry, so a line holding one gets no step.
|
|
13
|
+
*/
|
|
14
|
+
const typedArgs = (command) => {
|
|
15
|
+
const root = rootOf(command);
|
|
16
|
+
if (root.getOptionValue("fields") !== undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (command.options.some((option) => option.negate && typed(command, option.attributeName())))
|
|
19
|
+
return undefined;
|
|
20
|
+
const positional = command.registeredArguments.flatMap((argument, index) => {
|
|
21
|
+
const value = command.processedArgs[index];
|
|
22
|
+
const word = Array.isArray(value) ? value.join(" ") : value;
|
|
23
|
+
return word === undefined || word === "" ? [] : [[argument.name(), word]];
|
|
24
|
+
});
|
|
25
|
+
const own = command.options
|
|
26
|
+
.map((option) => option.attributeName())
|
|
27
|
+
.filter((key) => typed(command, key))
|
|
28
|
+
.map((key) => [snake(key), command.getOptionValue(key)]);
|
|
29
|
+
const global = CARRIED.filter((key) => typed(root, key)).map((key) => [snake(key), root.getOptionValue(key)]);
|
|
30
|
+
return Object.fromEntries([...positional, ...own, ...global]);
|
|
31
|
+
};
|
|
32
|
+
const declaresRow = (command) => {
|
|
33
|
+
const { rows } = environmentOf(command);
|
|
34
|
+
return rowShapeOf(command, rows && entityRows(rows)) !== GENERIC_ROW;
|
|
35
|
+
};
|
|
36
|
+
const pathOf = (command) => {
|
|
37
|
+
const words = [];
|
|
38
|
+
for (let at = command; at.parent; at = at.parent)
|
|
39
|
+
words.unshift(at.name());
|
|
40
|
+
return words.join(" ");
|
|
41
|
+
};
|
|
42
|
+
const step = (command, id, args, why) => ({
|
|
43
|
+
kind: "command",
|
|
44
|
+
step: id,
|
|
45
|
+
command: pathOf(command),
|
|
46
|
+
args,
|
|
47
|
+
why,
|
|
48
|
+
safe_to_auto_execute: true,
|
|
49
|
+
});
|
|
50
|
+
const compactStep = (command, args, why) => rootOf(command).getOptionValue("compact") === true || !declaresRow(command)
|
|
51
|
+
? []
|
|
52
|
+
: [
|
|
53
|
+
step(command, "output.compact", { ...Object.fromEntries(Object.entries(args).filter(([key]) => !NOT_WITH_COMPACT.has(key))), compact: true }, why),
|
|
54
|
+
];
|
|
55
|
+
/** The same read in a form that fits the cap: one line per item, then a `--limit` scaled down to fit (WC-OUT-22). */
|
|
56
|
+
export const tooLargeSteps = (command, { maxBytes, neededBytes }) => {
|
|
57
|
+
const args = typedArgs(command);
|
|
58
|
+
if (!args)
|
|
59
|
+
return [];
|
|
60
|
+
const limit = Number(command.getOptionValue("limit"));
|
|
61
|
+
const fits = Math.floor(((limit * maxBytes) / neededBytes) * 0.9);
|
|
62
|
+
return [
|
|
63
|
+
...compactStep(command, args, "the answer is over the output cap as JSON; one line per item keeps every ref"),
|
|
64
|
+
...(Number.isSafeInteger(limit) && fits >= 1 && fits < limit
|
|
65
|
+
? [step(command, "output.limit", { ...args, limit: fits }, `about ${fits} items fit under the output cap`)]
|
|
66
|
+
: []),
|
|
67
|
+
];
|
|
68
|
+
};
|
|
69
|
+
/** The step a large JSON answer adds: the same read, one line per item. */
|
|
70
|
+
export const largeAnswerSteps = (command) => {
|
|
71
|
+
const args = typedArgs(command);
|
|
72
|
+
return args ? compactStep(command, args, "the answer is large as JSON; one line per item keeps every ref") : [];
|
|
73
|
+
};
|
package/dist/cli/program.js
CHANGED
|
@@ -29,6 +29,7 @@ import { installHelp } from "./help/first-screen.js";
|
|
|
29
29
|
import { showInputFields } from "./input-fields.js";
|
|
30
30
|
import { MAX_BUFFERED_INPUT, provideInputPolicy } from "./input-policy.js";
|
|
31
31
|
import { checkJson, liftJson } from "./json-fields.js";
|
|
32
|
+
import { tooLargeSteps } from "./output-budget.js";
|
|
32
33
|
import { PreviewComplete, preview } from "./preview.js";
|
|
33
34
|
import { commandWords, isProfileName, liftProfile, sampleCommand, takesProfile, valuedOptions } from "./profile.js";
|
|
34
35
|
import { absolutePaths, relayTarget } from "./relay-route.js";
|
|
@@ -705,6 +706,15 @@ const commandPath = (program, rest) => {
|
|
|
705
706
|
}
|
|
706
707
|
return words.join(" ");
|
|
707
708
|
};
|
|
709
|
+
const budgetSteps = (program, path, failure) => {
|
|
710
|
+
const command = path ? commandAt(program, path) : undefined;
|
|
711
|
+
if (!command || !(failure instanceof CliError) || failure.details?.reason !== "output.too-large")
|
|
712
|
+
return [];
|
|
713
|
+
const { max_bytes: maxBytes, needed_bytes: neededBytes } = failure.details;
|
|
714
|
+
return typeof maxBytes === "number" && typeof neededBytes === "number"
|
|
715
|
+
? tooLargeSteps(command, { maxBytes, neededBytes })
|
|
716
|
+
: [];
|
|
717
|
+
};
|
|
708
718
|
const failed = ({ app }, program, rest, error, settled, debug) => {
|
|
709
719
|
const path = commandPath(program, program.args.length > 0 ? program.args : rest);
|
|
710
720
|
const told = error instanceof NoProfileHere && !commandAt(program, "setup") ? error.withoutSetup(path) : error;
|
|
@@ -718,7 +728,10 @@ const failed = ({ app }, program, rest, error, settled, debug) => {
|
|
|
718
728
|
...(effects ? { effects } : {}),
|
|
719
729
|
});
|
|
720
730
|
const { next: _named, ...said } = built.error;
|
|
721
|
-
const steps = failureSteps(program,
|
|
731
|
+
const steps = failureSteps(program, {
|
|
732
|
+
...built.error,
|
|
733
|
+
next: [...(built.error.next ?? []), ...budgetSteps(program, path, failure)],
|
|
734
|
+
});
|
|
722
735
|
const object = { ...built, error: { ...said, ...(steps.length > 0 ? { next: steps } : {}) } };
|
|
723
736
|
showInputFields(object, (path && commandAt(program, path)) || program);
|
|
724
737
|
const command = path || app.command;
|
package/dist/cli/settings.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { type Level, type PermissionKey } from "../sends/permissions.js";
|
|
|
7
7
|
import { type HistoryLimits } from "../services/history-limits.js";
|
|
8
8
|
import { type ConfigIssue } from "./config-issues.js";
|
|
9
9
|
import type { CommandPermissions } from "./permission-keys.js";
|
|
10
|
+
import { type DeclaredSetting } from "./setting-defaults.js";
|
|
10
11
|
export declare const plain: (rule: string) => (issue: v.BaseIssue<unknown>) => string;
|
|
11
12
|
export declare const count: 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>]>;
|
|
12
13
|
export declare const flag: v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>;
|
|
@@ -32,6 +33,8 @@ export interface SettingsExtension<T extends object = Record<never, never>> {
|
|
|
32
33
|
profile?: v.ObjectEntries;
|
|
33
34
|
/** For the whole program only, never per profile. */
|
|
34
35
|
defaults?: v.ObjectEntries;
|
|
36
|
+
/** Its own scalar settings, written into the file's `defaults` beside the shared ones (WC-CMD-30). */
|
|
37
|
+
settings?: readonly DeclaredSetting[];
|
|
35
38
|
/** Profiles this messenger keeps in files of its own, beyond the config file and the remembered accounts. */
|
|
36
39
|
profiles?: (env: NodeJS.ProcessEnv) => string[];
|
|
37
40
|
/**
|
|
@@ -229,6 +232,8 @@ export declare const settingsFor: <T extends object = Record<never, never>>(app:
|
|
|
229
232
|
kind?: ProfileKind | undefined;
|
|
230
233
|
root?: boolean;
|
|
231
234
|
}) => unknown;
|
|
235
|
+
migrate: (path: string) => string[];
|
|
236
|
+
declaredSettings: DeclaredSetting[];
|
|
232
237
|
allSettings: string[];
|
|
233
238
|
defaultsOnlyKeys: string[];
|
|
234
239
|
schema: v.StrictObjectSchema<{
|
package/dist/cli/settings.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { CliError, configFilePath, resolvePaths, saveConfigFile } from "@wirecat/cli-base";
|
|
3
3
|
import * as v from "valibot";
|
|
4
|
-
import { AI_ENTRIES, resolveAISettings } from "../analysis/settings.js";
|
|
4
|
+
import { AI_DEFAULTS, AI_ENTRIES, resolveAISettings } from "../analysis/settings.js";
|
|
5
5
|
import { parseDuration } from "../domain/duration.js";
|
|
6
6
|
import { DEFAULT_RERANK_MODEL, RERANK_MODELS } from "../embeddings/models.js";
|
|
7
7
|
import { envName, readOnlyLockOf } from "../ports/app.js";
|
|
@@ -14,6 +14,7 @@ import { checkConfigText, issueMessage, readConfigFile, readConfigText, savedPer
|
|
|
14
14
|
import { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
|
|
15
15
|
import { profilesHere } from "./messenger/accounts.js";
|
|
16
16
|
import { usableProfileName } from "./profile.js";
|
|
17
|
+
import { withSettingDefaults } from "./setting-defaults.js";
|
|
17
18
|
import { permissionCeiling } from "./setup-answers.js";
|
|
18
19
|
const DEFAULT_LIMIT = DEFAULT_CONFIG.defaults.limit;
|
|
19
20
|
const DEFAULT_KEEP_RUNS_FOR_DAYS = DEFAULT_CONFIG.defaults.keep_runs_for_days;
|
|
@@ -107,6 +108,70 @@ const SHARED_DEFAULTS_ONLY = {
|
|
|
107
108
|
update_check: v.optional(flag),
|
|
108
109
|
skill_hint: v.optional(flag),
|
|
109
110
|
};
|
|
111
|
+
/**
|
|
112
|
+
* Written into the file's `defaults` at the value the code applies when the file says nothing (WC-CMD-30). Left out:
|
|
113
|
+
* objects, and settings whose absence means something no value says (`timeout_ms`, `color`, `requests_per_minute`,
|
|
114
|
+
* the embedding model), since writing any value would change what they do.
|
|
115
|
+
*/
|
|
116
|
+
const SHARED_SETTINGS = [
|
|
117
|
+
{ key: "limit", default: DEFAULT_LIMIT, description: "how many items a list shows at once" },
|
|
118
|
+
{ key: "keep_runs_for_days", default: DEFAULT_KEEP_RUNS_FOR_DAYS, description: "days a run is kept" },
|
|
119
|
+
{
|
|
120
|
+
key: "sends_per_hour",
|
|
121
|
+
default: DEFAULT_SENDS_PER_HOUR,
|
|
122
|
+
description: "the most messages an account sends in an hour; a bot has none unless its section sets one",
|
|
123
|
+
},
|
|
124
|
+
{ key: "update_check", default: true, description: "check once a day for a newer version" },
|
|
125
|
+
{
|
|
126
|
+
key: "skill_hint",
|
|
127
|
+
default: true,
|
|
128
|
+
description: "tell an agent once a day that `skill install` would give it this tool's guide",
|
|
129
|
+
},
|
|
130
|
+
{ key: "record", default: true, description: "keep every run in runs list; false keeps failed runs only" },
|
|
131
|
+
{
|
|
132
|
+
key: "record_input",
|
|
133
|
+
default: true,
|
|
134
|
+
description: 'keep what was typed in a run\'s command line: true, false, or "failed" for failed runs only',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
key: "keep_successful_runs_for_days",
|
|
138
|
+
default: KEEP_SUCCESSFUL_RUNS_FOR_DAYS,
|
|
139
|
+
description: "days a successful run is kept",
|
|
140
|
+
},
|
|
141
|
+
{ key: "runs_size_limit_mb", default: RUNS_SIZE_LIMIT_MB, description: "the most megabytes all runs may take" },
|
|
142
|
+
{ key: "sender_colors", default: false, description: "color each sender's name in a conversation" },
|
|
143
|
+
{ key: "search_catch_up", default: false, description: "prepare local search after a gaps repair" },
|
|
144
|
+
{
|
|
145
|
+
key: "history_dialog_days",
|
|
146
|
+
default: DEFAULT_HISTORY_LIMITS.dialogDays,
|
|
147
|
+
description: "days of a one-to-one chat's pushed history the store keeps",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: "history_group_days",
|
|
151
|
+
default: DEFAULT_HISTORY_LIMITS.groupDays,
|
|
152
|
+
description: "days of a group's pushed history the store keeps",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
key: "history_group_members_max",
|
|
156
|
+
default: DEFAULT_HISTORY_LIMITS.groupMembersMax,
|
|
157
|
+
description: "a group with more people than this keeps no pushed history",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
key: "picture_search",
|
|
161
|
+
default: false,
|
|
162
|
+
description: "search and images embed read the pictures themselves, with a model on this computer",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
key: "search_reranker",
|
|
166
|
+
default: DEFAULT_RERANK_MODEL,
|
|
167
|
+
description: "the reranker that orders search's first hits, or off",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
key: "embedding_provider",
|
|
171
|
+
default: AI_DEFAULTS.embeddingProvider,
|
|
172
|
+
description: "local or openai: what turns text into vectors for search by meaning",
|
|
173
|
+
},
|
|
174
|
+
];
|
|
110
175
|
/** Where `profile` came from when none was named and the command needs none. */
|
|
111
176
|
export const NONE_NAMED = "none named";
|
|
112
177
|
/** A name for a command that speaks for no account; never chosen for one that does. */
|
|
@@ -174,12 +239,18 @@ export const settingsFor = (app, extension = {}) => {
|
|
|
174
239
|
const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys].filter((setting) => app.messenger !== false ||
|
|
175
240
|
!MESSENGER_ONLY_SETTINGS.includes(setting) ||
|
|
176
241
|
(app.sends === true && setting === "sends_per_hour"));
|
|
242
|
+
const readSchema = extension.schema ?? schema;
|
|
243
|
+
// An app with a schema of its own may refuse a shared key in `defaults`; writing it would break that file.
|
|
244
|
+
const declaredSettings = [...SHARED_SETTINGS, ...(extension.settings ?? [])]
|
|
245
|
+
.filter(({ key, default: value }) => allSettings.includes(key) && v.safeParse(readSchema, { defaults: { [key]: value } }).success)
|
|
246
|
+
.map((setting) => ({ ...setting, key: `defaults.${setting.key}` }));
|
|
247
|
+
// `defaults` needs no field of its own, so it is the one section created: every key then shows (RISK-2125).
|
|
248
|
+
const withDefaults = (raw, except = []) => withSettingDefaults(isScope(raw) && !Object.hasOwn(raw, "defaults") ? { ...raw, defaults: {} } : raw, declaredSettings.filter(({ key }) => !except.includes(key)));
|
|
177
249
|
const PROFILE = envName(app, "PROFILE");
|
|
178
250
|
const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
|
|
179
251
|
const TIMEOUT = envName(app, "TIMEOUT");
|
|
180
252
|
const REQUESTS_PER_MINUTE = envName(app, "REQUESTS_PER_MINUTE");
|
|
181
253
|
const configPathFor = ({ env = process.env, configDir }) => configFilePath(configDir ?? resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).config);
|
|
182
|
-
const readSchema = extension.schema ?? schema;
|
|
183
254
|
const context = (file) => ({ file, tool: app.command });
|
|
184
255
|
/** The file with every problem found; what loads is the file without the settings they name. */
|
|
185
256
|
const loadConfig = (path) => {
|
|
@@ -225,7 +296,7 @@ export const settingsFor = (app, extension = {}) => {
|
|
|
225
296
|
const configPath = configPathFor(options);
|
|
226
297
|
try {
|
|
227
298
|
if (!options.readOnly)
|
|
228
|
-
ensureDefaultConfig(configPath);
|
|
299
|
+
ensureDefaultConfig(configPath, withDefaults(DEFAULT_CONFIG).config);
|
|
229
300
|
}
|
|
230
301
|
catch (error) {
|
|
231
302
|
throw new CliError("configuration_error", error instanceof Error ? error.message : String(error));
|
|
@@ -435,7 +506,7 @@ export const settingsFor = (app, extension = {}) => {
|
|
|
435
506
|
throw new CliError("validation_error", `${setting} has no problem to remove — name the profile, or add --defaults, to unset a setting`);
|
|
436
507
|
if (!known && valueAt(raw, at) === undefined)
|
|
437
508
|
throw new CliError("validation_error", `no setting called "${setting}" — one of: ${allSettings.join(", ")}`);
|
|
438
|
-
saveConfigFile(path, withoutPath(raw, at).config);
|
|
509
|
+
saveConfigFile(path, withDefaults(withoutPath(raw, at).config, [at.join(".")]).config);
|
|
439
510
|
return null;
|
|
440
511
|
}
|
|
441
512
|
if (!known)
|
|
@@ -456,13 +527,25 @@ export const settingsFor = (app, extension = {}) => {
|
|
|
456
527
|
if (added)
|
|
457
528
|
throw new CliError("validation_error", `${setting} cannot be "${value}": ${added.what}`);
|
|
458
529
|
const checked = v.safeParse(readSchema, changed);
|
|
459
|
-
saveConfigFile(path, checked.success ? checked.output : changed);
|
|
530
|
+
saveConfigFile(path, withDefaults(checked.success ? checked.output : changed).config);
|
|
460
531
|
return valueAt(checked.success ? checked.output : changed, at) ?? null;
|
|
461
532
|
};
|
|
533
|
+
/** Each declared setting the file lacks, at its default, on the file as written; the keys added. */
|
|
534
|
+
const migrate = (path) => {
|
|
535
|
+
const read = readConfigFile(path);
|
|
536
|
+
if ("issue" in read)
|
|
537
|
+
throw new CliError("configuration_error", issueMessage([read.issue]));
|
|
538
|
+
const { config, added } = withDefaults(read.raw);
|
|
539
|
+
if (added.length > 0)
|
|
540
|
+
saveConfigFile(path, config);
|
|
541
|
+
return added;
|
|
542
|
+
};
|
|
462
543
|
return {
|
|
463
544
|
resolveSettings,
|
|
464
545
|
configuredProfiles,
|
|
465
546
|
changeSetting,
|
|
547
|
+
migrate,
|
|
548
|
+
declaredSettings,
|
|
466
549
|
allSettings,
|
|
467
550
|
defaultsOnlyKeys,
|
|
468
551
|
schema,
|