@wirecat/cli-core 0.555.0 → 0.557.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 +59 -0
- package/dist/cli/config-command.js +64 -2
- package/dist/cli/config-issues.d.ts +2 -0
- package/dist/cli/config-issues.js +11 -3
- package/dist/cli/config-repair.d.ts +26 -0
- package/dist/cli/config-repair.js +252 -0
- package/dist/cli/messenger/tasks-analysis-command.d.ts +8 -0
- package/dist/cli/messenger/tasks-analysis-command.js +184 -0
- package/dist/cli/messenger/tasks-command.js +11 -2
- package/dist/cli/steps.js +10 -0
- package/dist/domain/finance.d.ts +40 -0
- package/dist/domain/finance.js +39 -0
- package/dist/domain/money.d.ts +29 -0
- package/dist/domain/money.js +98 -0
- package/dist/domain/ref-summaries.js +2 -0
- package/dist/domain/references.d.ts +1 -1
- package/dist/domain/references.js +2 -0
- package/dist/entities/index.d.ts +64 -2
- package/dist/entities/index.js +5 -2
- package/dist/entities/task-results.d.ts +72 -0
- package/dist/entities/task-results.js +40 -1
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mail/cli/index.d.ts +1 -0
- package/dist/mail/cli/index.js +3 -0
- package/dist/mail/cli/marks.d.ts +26 -0
- package/dist/mail/cli/marks.js +51 -0
- package/dist/mail/cli/review.js +70 -1
- package/dist/mail/index.d.ts +1 -0
- package/dist/mail/index.js +1 -0
- package/dist/mail/marks.d.ts +56 -0
- package/dist/mail/marks.js +178 -0
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/sends/permissions.js +1 -0
- package/dist/services/proposals/kinds/finance.d.ts +4 -0
- package/dist/services/proposals/kinds/finance.js +40 -0
- package/dist/services/proposals/registry.js +3 -0
- package/dist/services/task-analysis.d.ts +58 -0
- package/dist/services/task-analysis.js +125 -0
- package/dist/store/exports.d.ts +2 -2
- package/dist/store/index.d.ts +2 -1
- package/dist/store/sqlite/email-activity.d.ts +31 -0
- package/dist/store/sqlite/email-activity.js +38 -0
- package/dist/store/sqlite/emails.d.ts +2 -0
- package/dist/store/sqlite/emails.js +1 -0
- package/dist/store/sqlite/entity-types.d.ts +2 -0
- package/dist/store/sqlite/entity-types.js +2 -0
- package/dist/store/sqlite/finance.d.ts +199 -0
- package/dist/store/sqlite/finance.js +514 -0
- package/dist/store/sqlite/knowledge.js +3 -1
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/migrations.generated.js +35 -0
- package/dist/store/sqlite/ref-summaries.js +33 -0
- package/dist/store/sqlite/schema.d.ts +1043 -0
- package/dist/store/sqlite/schema.js +98 -0
- package/dist/store/sqlite/task-analysis-marks.d.ts +7 -0
- package/dist/store/sqlite/task-analysis-marks.js +27 -0
- package/dist/store/sqlite/things.js +2 -0
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/dist/store/sync.d.ts +5 -1
- package/dist/store/sync.js +4 -0
- package/dist/store/types.d.ts +25 -0
- package/package.json +1 -1
- package/parity.json +29 -0
- package/skills/follow-ups/SKILL.md +22 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,10 +7,54 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.557.0 — 02.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Invoices and payments in the store (store version 45).** `store.finance` adds, lists and shows invoices with
|
|
15
|
+
their lines and payments, allocates a payment to invoices and sums what open invoices still owe per currency
|
|
16
|
+
(`openTotals`). Money is an integer in the currency's minor units (`CURRENCY_SCALES`, ISO 4217), passed and read as
|
|
17
|
+
decimal strings; an unknown currency is refused and nothing is converted. A total must equal its lines, and an
|
|
18
|
+
allocation never exceeds the payment or the invoice or crosses currencies; triggers back the code. The status
|
|
19
|
+
follows confirmed allocations; only the owner issues a draft or voids an invoice.
|
|
20
|
+
- **An agent's or an import's invoice or payment is a proposal.** The owner's entry is confirmed at once; a bot's
|
|
21
|
+
waits as `invoice` or `payment` in `proposalRegistry`, and only the owner decides it. A pending payment's
|
|
22
|
+
allocations never count against an invoice, so they never block the owner's own. Confirming one counts them in the
|
|
23
|
+
same write, or is refused while other confirmed payments already cover the invoice; rejecting drops them.
|
|
24
|
+
- **`invoice:<id>` and `payment:<id>` references,** with `refs.summaries` titles such as
|
|
25
|
+
`42 · 50 000.00 RUB to ООО Ромашка, due 15.11 (partly paid)`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Mail `review` gives three examples**, as the command lint asks; `--waiting` stays in its help.
|
|
30
|
+
|
|
31
|
+
## 0.556.0 — 02.11.2026
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **A processing mark per holder and mail account** (`src/mail/marks.ts`): `review --holder <name>` reads what is
|
|
36
|
+
stored past the holder's mark, by store order (`emails.id`), so mail stored late is not skipped; bulk mail, envelopes
|
|
37
|
+
and drafts are skipped and counted, partial mail held until whole. `messages acknowledge --holder <name> --cursor
|
|
38
|
+
<cursor>` moves the mark forward; a read-only scheduled run may run it. Kept in `sync_cursors` as
|
|
39
|
+
`consumer:<holder>`, no schema change. `readPastMark`, `acknowledgeMark`, `marksOf`, `markItem` and the
|
|
40
|
+
`mail_mark` entity are exported for `status`; `store.mail` gains `emailsAfter`, `newestEmailId`, `statesLike` and
|
|
41
|
+
`emailHits`.
|
|
42
|
+
|
|
10
43
|
## 0.555.0 — 02.11.2026
|
|
11
44
|
|
|
12
45
|
### Added
|
|
13
46
|
|
|
47
|
+
- **Each chat keeps its own point of reading for tasks.** `review --new`, `inbox --new`, the messenger's read state
|
|
48
|
+
and other agents move their own points, so a message one of them showed could be skipped by the next task pass.
|
|
49
|
+
`tasks status` lists every chat that can hold tasks (not channels or Saved Messages) with how far it was read for
|
|
50
|
+
tasks, how many stored messages wait after that, and how many arrived late inside the stretch already read
|
|
51
|
+
(history fetched afterwards). `tasks review <chat>` gives the waiting messages, oldest first, with the chat's open
|
|
52
|
+
tasks, and moves nothing; `tasks acknowledge <chat> <message>` moves the point once the tasks are written, never
|
|
53
|
+
back. A chat never read starts 14 days back; `--since-time` reads a stretch again. The points live in
|
|
54
|
+
`store_settings`, so no migration; `SyncStore.taskAnalysisMarks`, `taskAnalysisMark` and `setTaskAnalysisMark`
|
|
55
|
+
read and write them. The follow-ups skill uses them. New permission key `tasks.acknowledge`.
|
|
56
|
+
A read again only updates and closes tasks: `tasks add` refuses a message the chat's point already passed
|
|
57
|
+
(`task.already-read`) unless given `--open-new`; a message that already has a task is returned as before.
|
|
14
58
|
- **`review` for mail CLIs** (`mailReadCommands`): every stored email, both sides, in threads that changed since a
|
|
15
59
|
point, in messenger `review`'s item shape plus `email` (people, labels, sender score and the marks `bulk`,
|
|
16
60
|
`list_id`, `rule_id`); `--new` keeps a point per mailbox, `--unanswered [duration]` keeps threads whose newest email
|
|
@@ -21,6 +65,21 @@ shape of this file.
|
|
|
21
65
|
`email` gains optional `bulk`, `list_id` and `rule_id`, and its `summary` view may carry `email`.
|
|
22
66
|
- **`tasks agenda` over an email account fills `owed`, `waiting` and `stale`** from its threads, with the same rule
|
|
23
67
|
of whose turn a thread is as mail `review`; before, they were empty and `stale` true.
|
|
68
|
+
- **`config repair` mends a settings file whose JSON is broken only in its syntax**, without AI or network (the file
|
|
69
|
+
may hold keys). It fixes a byte-order mark, `//` and `/* */` comments, trailing commas, a missing comma between
|
|
70
|
+
entries on separate lines, unquoted keys, single-quoted text, and closing brackets missing at the end. Every value,
|
|
71
|
+
permissions included, stays as written, in the file's key order. The old file is kept beside it as
|
|
72
|
+
`config.json.bak-<time>` in its old mode, and the new one is written atomically at 0600. It refuses damage with
|
|
73
|
+
more than one reading (two entries on one line, a key twice, a bare word as a value, a file that stops after a
|
|
74
|
+
closing bracket or inside a value, text that is not JSON or not text) with `configuration_error` and the line and
|
|
75
|
+
column, and changes nothing. It prints the line and kind of each fix, never a value; `--dry-run` writes nothing.
|
|
76
|
+
`--json` gives `file`, `found`, `repaired`, `dry_run`, `changes` (`{ line, kind }`), `backup`, and for a file that
|
|
77
|
+
needs nothing `reason` (`nothing to repair`, `no settings file`); a refusal is the error reason `config.unrepairable`
|
|
78
|
+
with `details.file`, `details.line` and `details.column`.
|
|
79
|
+
It changes what a later run may do, so the read-only lock refuses it and an MCP server runs it only with
|
|
80
|
+
`mcp.authority`; an agent's shell may not repair a file cut off before its end. `repairConfigText` exports it.
|
|
81
|
+
- **`doctor` offers `<tool> config repair` as the `fix` of an `invalid_json` issue** when the repair would succeed, so
|
|
82
|
+
the desktop can show a Repair button; a command refused on such a file names it too.
|
|
24
83
|
|
|
25
84
|
### Fixed
|
|
26
85
|
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { CliError, pathsAreOverridden, writeSecurely } from "@wirecat/cli-base";
|
|
2
3
|
import { annotate } from "@wirecat/cli-base/commands";
|
|
3
4
|
import { Command, Option } from "commander";
|
|
4
5
|
import { envName } from "../ports/app.js";
|
|
5
6
|
import { agentOf } from "../runs/run-context.js";
|
|
6
7
|
import { AUTHORITY_KEY, BOT_API_KEY, LEVELS, levelFor } from "../sends/permissions.js";
|
|
8
|
+
import { fixVerb } from "./config-issues.js";
|
|
9
|
+
import { REPAIR_WORDS, repairConfigText } from "./config-repair.js";
|
|
7
10
|
import { baseContext, environmentOf } from "./context.js";
|
|
8
11
|
import { grantsAuthority, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
|
|
9
12
|
import { itemOf, renderAnswer } from "./envelope.js";
|
|
10
13
|
import { knownBeside, knownPermissionKeys, unknownPermissionIssues, } from "./permission-keys.js";
|
|
14
|
+
import { profileFree } from "./profile.js";
|
|
11
15
|
import { fromFile, NONE_NAMED } from "./settings.js";
|
|
12
16
|
import { nextStep } from "./steps.js";
|
|
13
17
|
import { changeStoreSetting, isStoreSetting, offersStoreSetting, storeSettings, storeSettingsOf, } from "./store-settings.js";
|
|
@@ -51,7 +55,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
|
|
|
51
55
|
...(settings.configIssues ?? []),
|
|
52
56
|
...unknownPermissionIssues(this, settings, app.command, permissionKey),
|
|
53
57
|
])
|
|
54
|
-
renderer.warn(`${issue.what}, so it is left out${issue.fix ? ` — \`${issue.fix}\`
|
|
58
|
+
renderer.warn(`${issue.what}, so it is left out${issue.fix ? ` — \`${issue.fix}\` ${fixVerb(issue)}s it` : ""}`);
|
|
55
59
|
if (overridden) {
|
|
56
60
|
renderer.note(`${envName(app, "CONFIG_DIR")}, ${envName(app, "STATE_DIR")} or ${envName(app, "CACHE_DIR")} is set — ` +
|
|
57
61
|
"a session is still read from the usual keyring entry, but a sign-in made with them is kept apart, in a " +
|
|
@@ -140,8 +144,66 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
|
|
|
140
144
|
}));
|
|
141
145
|
});
|
|
142
146
|
}
|
|
147
|
+
command.addCommand(repairCommand(app, config));
|
|
143
148
|
return command;
|
|
144
149
|
};
|
|
150
|
+
/**
|
|
151
|
+
* Mends JSON syntax with one reading and keeps every value (NEED-2178): no network, no AI, since the file may hold
|
|
152
|
+
* keys. Anything else is refused with nothing changed.
|
|
153
|
+
*/
|
|
154
|
+
const repairCommand = (app, config) => profileFree(grantsAuthority(annotate(new Command("repair"), {
|
|
155
|
+
effects: LOCAL_WRITE,
|
|
156
|
+
examples: [`${app.command} config repair`, `${app.command} config repair --dry-run`],
|
|
157
|
+
}), { unless: "dryRun" }))
|
|
158
|
+
.summary("fix the settings file's JSON syntax, keeping every value")
|
|
159
|
+
.description("fix broken JSON syntax in the settings file — comments, a missing or extra comma, unquoted keys, single " +
|
|
160
|
+
"quotes, missing closing brackets at the end — keeping every value, with a backup beside it; refuses " +
|
|
161
|
+
"anything with more than one reading and changes nothing then")
|
|
162
|
+
.option("--dry-run", "say what it would fix, and change nothing")
|
|
163
|
+
.action(function () {
|
|
164
|
+
const dryRun = this.opts().dryRun === true;
|
|
165
|
+
const context = baseContext(this, (flags, options) => config.resolveSettings(flags, { ...options, readOnly: true, needsProfile: false, withIssues: true }));
|
|
166
|
+
const { settings, renderer, env } = context;
|
|
167
|
+
const file = settings.configPath;
|
|
168
|
+
// The desktop reads these names (wirecat-desktop#83).
|
|
169
|
+
const answer = (fields) => renderAnswer(this, context, itemOf({ file, found: existsSync(file), dry_run: dryRun, changes: [], backup: null, ...fields }));
|
|
170
|
+
if (!existsSync(file)) {
|
|
171
|
+
answer({ repaired: false, reason: "no settings file" });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const bytes = readFileSync(file);
|
|
175
|
+
const result = repairConfigText(bytes.toString("utf8"));
|
|
176
|
+
if (result.state === "valid") {
|
|
177
|
+
answer({ repaired: false, reason: "nothing to repair" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (result.state === "refused")
|
|
181
|
+
throw new CliError("configuration_error", `${file}: cannot repair it safely — ${result.why}, at line ${result.line}, column ${result.column}; ` +
|
|
182
|
+
"nothing was changed, so fix that part by hand", { reason: "config.unrepairable", file, line: result.line, column: result.column });
|
|
183
|
+
const cut = result.fixes.some((one) => one.kind === "missing_closer");
|
|
184
|
+
// What came after the cut may have been a deny; only the owner decides the shorter file is what they meant.
|
|
185
|
+
if (cut && !dryRun && (environmentOf(this).agent || agentOf(env)))
|
|
186
|
+
throw new CliError("permission_error", `${file} stops before its end, and a setting after the cut may be lost — the owner repairs it in a terminal`);
|
|
187
|
+
let backup = null;
|
|
188
|
+
if (!dryRun) {
|
|
189
|
+
backup = backupPathFor(file);
|
|
190
|
+
writeSecurely(backup, bytes, statSync(file).mode & 0o777);
|
|
191
|
+
writeSecurely(file, result.text, 0o600);
|
|
192
|
+
}
|
|
193
|
+
answer({ repaired: !dryRun, changes: result.fixes, backup });
|
|
194
|
+
for (const one of result.fixes)
|
|
195
|
+
renderer.note(`line ${one.line}: ${dryRun ? "would fix" : "fixed"} ${REPAIR_WORDS[one.kind]}`);
|
|
196
|
+
if (backup)
|
|
197
|
+
renderer.note(`the old file is kept as ${backup}`);
|
|
198
|
+
});
|
|
199
|
+
/** Beside the file, so the owner finds it; a second repair in the same millisecond never replaces the first. */
|
|
200
|
+
const backupPathFor = (file) => {
|
|
201
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
202
|
+
let path = `${file}.bak-${stamp}`;
|
|
203
|
+
for (let n = 2; existsSync(path); n++)
|
|
204
|
+
path = `${file}.bak-${stamp}-${n}`;
|
|
205
|
+
return path;
|
|
206
|
+
};
|
|
145
207
|
/**
|
|
146
208
|
* A key no command or write is checked against would be saved and do nothing (BUG-137) — whether it
|
|
147
209
|
* comes as `permissions.<key>` or inside a whole `permissions` object.
|
|
@@ -63,6 +63,8 @@ export declare const withoutSetting: (raw: unknown, setting: string) => {
|
|
|
63
63
|
};
|
|
64
64
|
/** The public shape: where in the file is the loader's own business. */
|
|
65
65
|
export declare const publicIssue: ({ at: _, ...issue }: LocatedIssue) => ConfigIssue;
|
|
66
|
+
/** What an issue's `fix` does to it: `config repair` mends the file, `config unset` removes the setting. */
|
|
67
|
+
export declare const fixVerb: (issue: ConfigIssue) => string;
|
|
66
68
|
/**
|
|
67
69
|
* One line for a command that refuses to run on the file: the first problem, how to fix it, and how many more.
|
|
68
70
|
* `doctor` is the command that lists them all, as the reader types it.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import * as v from "valibot";
|
|
3
|
+
import { repairConfigText } from "./config-repair.js";
|
|
3
4
|
const isNode = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
5
|
const SECTIONS = ["personal", "bot"];
|
|
5
6
|
const SHELL_SAFE = /^[A-Za-z0-9_.:@-]*$/;
|
|
@@ -295,6 +296,8 @@ export const withoutPath = (raw, at) => {
|
|
|
295
296
|
export const withoutSetting = (raw, setting) => withoutPath(raw, settingPathOf(setting));
|
|
296
297
|
/** The public shape: where in the file is the loader's own business. */
|
|
297
298
|
export const publicIssue = ({ at: _, ...issue }) => issue;
|
|
299
|
+
/** What an issue's `fix` does to it: `config repair` mends the file, `config unset` removes the setting. */
|
|
300
|
+
export const fixVerb = (issue) => (issue.kind === "invalid_json" ? "repair" : "remove");
|
|
298
301
|
/**
|
|
299
302
|
* One line for a command that refuses to run on the file: the first problem, how to fix it, and how many more.
|
|
300
303
|
* `doctor` is the command that lists them all, as the reader types it.
|
|
@@ -305,7 +308,7 @@ export const issueMessage = (issues, doctor = "doctor") => {
|
|
|
305
308
|
return "";
|
|
306
309
|
const more = issues.length - 1;
|
|
307
310
|
return (`${first.file}: ${first.what}` +
|
|
308
|
-
(first.fix ? ` —
|
|
311
|
+
(first.fix ? ` — ${fixVerb(first)} it with \`${first.fix}\`` : "") +
|
|
309
312
|
(more === 0 ? "" : ` (and ${more} more problem${more === 1 ? "" : "s"} — \`${doctor}\` lists them all)`));
|
|
310
313
|
};
|
|
311
314
|
/**
|
|
@@ -316,8 +319,13 @@ export const issueMessage = (issues, doctor = "doctor") => {
|
|
|
316
319
|
*/
|
|
317
320
|
export const checkConfigText = (text, { file, tool, schema, knownPermissions, }) => {
|
|
318
321
|
const parsed = parseConfigText(text, file);
|
|
319
|
-
if ("issue" in parsed)
|
|
320
|
-
|
|
322
|
+
if ("issue" in parsed) {
|
|
323
|
+
const issue = publicIssue(parsed.issue);
|
|
324
|
+
// Offered only where it would work, so the desktop never shows a Repair button that fails.
|
|
325
|
+
if (repairConfigText(text).state === "repaired")
|
|
326
|
+
issue.fix = `${tool} config repair`;
|
|
327
|
+
return { issues: [issue], config: undefined };
|
|
328
|
+
}
|
|
321
329
|
// valibot reads a list as an object, so `[]` would pass for `{}`.
|
|
322
330
|
if (!isNode(parsed.raw))
|
|
323
331
|
return { issues: [wholeFile(file, "the file must hold a JSON object")], config: undefined };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repairs a settings file whose JSON is broken only in its syntax, without AI and without guessing (NEED-2178): a
|
|
3
|
+
* file can hold keys and tokens, so it never leaves the machine. A damage with one reading is fixed; anything else
|
|
4
|
+
* is refused, and the caller changes nothing.
|
|
5
|
+
*/
|
|
6
|
+
export type RepairKind = "byte_order_mark" | "comment" | "trailing_comma" | "missing_comma" | "unquoted_key" | "single_quotes" | "missing_closer";
|
|
7
|
+
/** Where and what, never the text: a value may be a secret. */
|
|
8
|
+
export interface RepairFix {
|
|
9
|
+
line: number;
|
|
10
|
+
kind: RepairKind;
|
|
11
|
+
}
|
|
12
|
+
export type RepairResult = {
|
|
13
|
+
state: "valid";
|
|
14
|
+
} | {
|
|
15
|
+
state: "repaired";
|
|
16
|
+
text: string;
|
|
17
|
+
fixes: RepairFix[];
|
|
18
|
+
} | {
|
|
19
|
+
state: "refused";
|
|
20
|
+
line: number;
|
|
21
|
+
column: number;
|
|
22
|
+
why: string;
|
|
23
|
+
};
|
|
24
|
+
/** What each fix mends, as a person reads it. */
|
|
25
|
+
export declare const REPAIR_WORDS: Record<RepairKind, string>;
|
|
26
|
+
export declare const repairConfigText: (text: string) => RepairResult;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repairs a settings file whose JSON is broken only in its syntax, without AI and without guessing (NEED-2178): a
|
|
3
|
+
* file can hold keys and tokens, so it never leaves the machine. A damage with one reading is fixed; anything else
|
|
4
|
+
* is refused, and the caller changes nothing.
|
|
5
|
+
*/
|
|
6
|
+
/** What each fix mends, as a person reads it. */
|
|
7
|
+
export const REPAIR_WORDS = {
|
|
8
|
+
byte_order_mark: "an invisible byte-order mark at the start",
|
|
9
|
+
comment: "a comment, which JSON does not allow",
|
|
10
|
+
trailing_comma: "a comma before a closing bracket",
|
|
11
|
+
missing_comma: "a missing comma",
|
|
12
|
+
unquoted_key: "a key without double quotes",
|
|
13
|
+
single_quotes: "text in single quotes",
|
|
14
|
+
missing_closer: "the file stops before its closing brackets — whatever followed this line was already lost",
|
|
15
|
+
};
|
|
16
|
+
class GiveUp {
|
|
17
|
+
at;
|
|
18
|
+
why;
|
|
19
|
+
constructor(at, why) {
|
|
20
|
+
this.at = at;
|
|
21
|
+
this.why = why;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/;
|
|
25
|
+
const LITERAL = /^(true|false|null)/;
|
|
26
|
+
const KEY = /^[A-Za-z_$][\w$.-]*/;
|
|
27
|
+
const ENDS_VALUE = /^[\s,\]}/]?$/;
|
|
28
|
+
export const repairConfigText = (text) => {
|
|
29
|
+
try {
|
|
30
|
+
JSON.parse(text);
|
|
31
|
+
return { state: "valid" };
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
const fixes = [];
|
|
35
|
+
const lineAt = (offset) => text.slice(0, offset).split("\n").length;
|
|
36
|
+
const fix = (kind, offset) => fixes.push({ kind, line: lineAt(offset) });
|
|
37
|
+
const giveUp = (why, offset = at) => {
|
|
38
|
+
throw new GiveUp(offset, why);
|
|
39
|
+
};
|
|
40
|
+
let at = 0;
|
|
41
|
+
let truncated = false;
|
|
42
|
+
let last = "open";
|
|
43
|
+
let lastEnd = 0;
|
|
44
|
+
// A nested value() moves it, which TypeScript's narrowing inside the loop cannot see.
|
|
45
|
+
const previous = () => last;
|
|
46
|
+
const skip = () => {
|
|
47
|
+
for (;;) {
|
|
48
|
+
while (/\s/.test(text[at] ?? ""))
|
|
49
|
+
at++;
|
|
50
|
+
if (text.startsWith("//", at)) {
|
|
51
|
+
fix("comment", at);
|
|
52
|
+
while (at < text.length && text[at] !== "\n")
|
|
53
|
+
at++;
|
|
54
|
+
}
|
|
55
|
+
else if (text.startsWith("/*", at)) {
|
|
56
|
+
const end = text.indexOf("*/", at + 2);
|
|
57
|
+
if (end < 0)
|
|
58
|
+
giveUp("a /* comment is never closed");
|
|
59
|
+
fix("comment", at);
|
|
60
|
+
at = end + 2;
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const string = () => {
|
|
67
|
+
const quote = text[at];
|
|
68
|
+
const start = at;
|
|
69
|
+
if (quote === "'")
|
|
70
|
+
fix("single_quotes", at);
|
|
71
|
+
at++;
|
|
72
|
+
let body = "";
|
|
73
|
+
for (;;) {
|
|
74
|
+
if (at >= text.length)
|
|
75
|
+
giveUp("the file stops inside a quoted text", start);
|
|
76
|
+
const char = text[at];
|
|
77
|
+
if (char === quote)
|
|
78
|
+
break;
|
|
79
|
+
if (text.charCodeAt(at) < 0x20)
|
|
80
|
+
giveUp("a line break inside a quoted text");
|
|
81
|
+
if (char === "\\") {
|
|
82
|
+
const next = text[at + 1] ?? "";
|
|
83
|
+
if (quote === "'" && next === "'")
|
|
84
|
+
body += "'";
|
|
85
|
+
else if (/^["\\/bfnrt]$/.test(next))
|
|
86
|
+
body += `\\${next}`;
|
|
87
|
+
else if (next === "u" && /^[0-9a-fA-F]{4}$/.test(text.slice(at + 2, at + 6))) {
|
|
88
|
+
body += text.slice(at, at + 6);
|
|
89
|
+
at += 4;
|
|
90
|
+
}
|
|
91
|
+
else
|
|
92
|
+
giveUp("an escape JSON has no meaning for");
|
|
93
|
+
at += 2;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
body += char === '"' ? '\\"' : char;
|
|
97
|
+
at++;
|
|
98
|
+
}
|
|
99
|
+
at++;
|
|
100
|
+
return JSON.parse(`"${body}"`);
|
|
101
|
+
};
|
|
102
|
+
const key = () => {
|
|
103
|
+
const char = text[at] ?? "";
|
|
104
|
+
if (char === '"' || char === "'")
|
|
105
|
+
return string();
|
|
106
|
+
const word = KEY.exec(text.slice(at))?.[0];
|
|
107
|
+
if (!word)
|
|
108
|
+
return giveUp("something other than a key where a key should be");
|
|
109
|
+
fix("unquoted_key", at);
|
|
110
|
+
at += word.length;
|
|
111
|
+
return word;
|
|
112
|
+
};
|
|
113
|
+
const scalar = () => {
|
|
114
|
+
const char = text[at] ?? "";
|
|
115
|
+
if (char === '"' || char === "'")
|
|
116
|
+
return { json: JSON.stringify(string()) };
|
|
117
|
+
const word = (NUMBER.exec(text.slice(at)) ?? LITERAL.exec(text.slice(at)))?.[0];
|
|
118
|
+
if (!word || !ENDS_VALUE.test(text[at + word.length] ?? ""))
|
|
119
|
+
return giveUp("a value that is not JSON — text needs quotes, and only true, false and null go without");
|
|
120
|
+
// A number the file stops on may be the start of a longer one.
|
|
121
|
+
if (at + word.length === text.length && NUMBER.test(word))
|
|
122
|
+
giveUp("the file stops inside a number");
|
|
123
|
+
at += word.length;
|
|
124
|
+
return { json: word };
|
|
125
|
+
};
|
|
126
|
+
/** Members of an object or items of a list, with every syntax fix that has one reading. */
|
|
127
|
+
const members = (close, one) => {
|
|
128
|
+
const out = [];
|
|
129
|
+
at++;
|
|
130
|
+
last = "open";
|
|
131
|
+
let comma = -1;
|
|
132
|
+
for (;;) {
|
|
133
|
+
skip();
|
|
134
|
+
if (truncated)
|
|
135
|
+
return out;
|
|
136
|
+
if (at >= text.length) {
|
|
137
|
+
if (previous() === "close")
|
|
138
|
+
giveUp("the file stops after a closing bracket, so which brackets are missing has two readings");
|
|
139
|
+
if (previous() === "comma")
|
|
140
|
+
fix("trailing_comma", comma);
|
|
141
|
+
fix("missing_closer", text.length);
|
|
142
|
+
truncated = true;
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
const char = text[at];
|
|
146
|
+
if (char === close) {
|
|
147
|
+
if (previous() === "comma")
|
|
148
|
+
fix("trailing_comma", comma);
|
|
149
|
+
at++;
|
|
150
|
+
last = "close";
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
if (char === "}" || char === "]")
|
|
154
|
+
giveUp(`a ${char} where ${close} should close`);
|
|
155
|
+
if (char === ",") {
|
|
156
|
+
if (previous() !== "value" && previous() !== "close")
|
|
157
|
+
giveUp("a comma with nothing before it");
|
|
158
|
+
comma = at;
|
|
159
|
+
at++;
|
|
160
|
+
last = "comma";
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (previous() === "value" || previous() === "close") {
|
|
164
|
+
if (lineAt(at) === lineAt(lastEnd))
|
|
165
|
+
giveUp("two entries on one line with no comma between them");
|
|
166
|
+
fix("missing_comma", lastEnd);
|
|
167
|
+
}
|
|
168
|
+
out.push(one());
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const value = () => {
|
|
172
|
+
const char = text[at] ?? "";
|
|
173
|
+
let node;
|
|
174
|
+
if (char === "{") {
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
node = {
|
|
177
|
+
object: members("}", () => {
|
|
178
|
+
const start = at;
|
|
179
|
+
const name = key();
|
|
180
|
+
// JSON.parse keeps the last of two, which could lift a deny written first.
|
|
181
|
+
if (seen.has(name))
|
|
182
|
+
giveUp("a key appears twice in one object", start);
|
|
183
|
+
seen.add(name);
|
|
184
|
+
skip();
|
|
185
|
+
if (at >= text.length)
|
|
186
|
+
giveUp("the file stops after a key, before its value");
|
|
187
|
+
if (text[at] !== ":")
|
|
188
|
+
giveUp("a key with no colon after it");
|
|
189
|
+
at++;
|
|
190
|
+
skip();
|
|
191
|
+
if (at >= text.length)
|
|
192
|
+
giveUp("the file stops after a key, before its value");
|
|
193
|
+
return [name, value()];
|
|
194
|
+
}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
else if (char === "[") {
|
|
198
|
+
node = {
|
|
199
|
+
array: members("]", () => {
|
|
200
|
+
return value();
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
else
|
|
205
|
+
node = scalar();
|
|
206
|
+
if (!truncated) {
|
|
207
|
+
last = "json" in node ? "value" : "close";
|
|
208
|
+
lastEnd = at;
|
|
209
|
+
}
|
|
210
|
+
return node;
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
if (text.includes("\u0000") || text.includes(String.fromCharCode(0xfffd)))
|
|
214
|
+
giveUp("the file is not text", 0);
|
|
215
|
+
if (text.charCodeAt(0) === 0xfeff) {
|
|
216
|
+
fix("byte_order_mark", 0);
|
|
217
|
+
at = 1;
|
|
218
|
+
}
|
|
219
|
+
skip();
|
|
220
|
+
if (text[at] !== "{")
|
|
221
|
+
giveUp(at >= text.length ? "the file is empty" : "the file does not start with {");
|
|
222
|
+
const root = value();
|
|
223
|
+
skip();
|
|
224
|
+
if (at < text.length)
|
|
225
|
+
giveUp("more text after the closing }");
|
|
226
|
+
const repaired = `${serialise(root, "")}\n`;
|
|
227
|
+
JSON.parse(repaired);
|
|
228
|
+
return { state: "repaired", text: repaired, fixes };
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (!(error instanceof GiveUp))
|
|
232
|
+
throw error;
|
|
233
|
+
const before = text.slice(0, error.at).split("\n");
|
|
234
|
+
return {
|
|
235
|
+
state: "refused",
|
|
236
|
+
line: before.length,
|
|
237
|
+
column: (before.at(-1)?.length ?? 0) + 1,
|
|
238
|
+
why: error.why,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/** `JSON.stringify(value, null, 2)` as `saveConfigFile` writes it, but in the file's own key order and numbers. */
|
|
243
|
+
const serialise = (node, indent) => {
|
|
244
|
+
if ("json" in node)
|
|
245
|
+
return node.json;
|
|
246
|
+
const inner = `${indent} `;
|
|
247
|
+
const parts = "object" in node
|
|
248
|
+
? node.object.map(([name, item]) => `${inner}${JSON.stringify(name)}: ${serialise(item, inner)}`)
|
|
249
|
+
: node.array.map((item) => `${inner}${serialise(item, inner)}`);
|
|
250
|
+
const [open, close] = "object" in node ? ["{", "}"] : ["[", "]"];
|
|
251
|
+
return parts.length === 0 ? `${open}${close}` : `${open}\n${parts.join(",\n")}\n${indent}${close}`;
|
|
252
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type Messenger } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* Each chat's own point of reading for tasks (`../../services/task-analysis.ts`): how far it was read, what waits, and
|
|
5
|
+
* the step that moves it. Apart from `inbox --new`, `review --new` and the messenger's read state, which other
|
|
6
|
+
* readers move.
|
|
7
|
+
*/
|
|
8
|
+
export declare const analysisCommands: (tasks: Command, messenger: Messenger) => void;
|