@wirecat/cli-core 0.401.0 → 0.402.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 +10 -0
- package/dist/cli/bot/api.js +5 -2
- package/dist/cli/config-command.js +13 -7
- package/dist/cli/context.d.ts +2 -0
- package/dist/cli/program.js +5 -1
- package/dist/mcp/agent.js +4 -2
- package/dist/search/dictionary.d.ts +20 -0
- package/dist/search/dictionary.js +106 -0
- package/dist/sends/permissions.d.ts +9 -0
- package/dist/sends/permissions.js +12 -0
- package/dist/store/sqlite/transcript-query.js +15 -5
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.402.0 — 24.10.2026
|
|
11
|
+
|
|
12
|
+
### Changed — may break callers
|
|
13
|
+
|
|
14
|
+
- **MCP offers `bot api`, behind `bot.api`.** The generated `bot api <operation>` commands (raw calls to Bot API methods, which skip the send guard and the bot's recipient list) are bot MCP tools again, offered only where the profile sets `permissions.bot.api` to `allow`; it is `deny` by default for an MCP server, so nothing changes until the owner turns it on, and a broader `bot: allow` does not lift it. A call made after the owner turned it off is refused naming the key and the terminal command; an agent cannot set the key through `config set` (the guard on `permissions.mcp…` covers `permissions.bot.api`). Breaking for a CLI: `generatedApiCommand` no longer marks the group `mcp: false`, and each operation now declares effects (`remote: read` or `write`), so `commands --json` shows them and a catalogue built with `toolsFromCommands` without `levels` lists the operations; update a pinned bot catalogue and a document that lists what MCP leaves out. Under `<PREFIX>_READ_ONLY` a write operation now refuses on the command line too. The command line's `bot api` is otherwise unchanged.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Voice-note search no longer turns an English word into another real English word.** A word in a transcript widens to the spellings speech recognition garbles only past a dictionary of its language: Russian as before, and now English and Spanish, following the store's word-form languages (`search_stemmers`). Before, «cost» also found «cast». A Latin word with no dictionary for its language, and English and Spanish number words, no longer widen.
|
|
19
|
+
|
|
10
20
|
## 0.401.0 — 24.10.2026
|
|
11
21
|
|
|
12
22
|
### Changed — may break callers
|
package/dist/cli/bot/api.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { annotate } from "@wirecat/cli-base/commands";
|
|
3
3
|
import { Command, Option } from "commander";
|
|
4
|
+
import { REMOTE_READ, REMOTE_WRITE } from "../effects.js";
|
|
4
5
|
import { apiFlagOf, apiOptionKey, readApiBody } from "./api-input.js";
|
|
5
6
|
export const generatedApiCommand = (api) => {
|
|
6
|
-
const group =
|
|
7
|
+
const group = new Command("api").description(api.description ?? "every operation of the official Bot API, generated from its schema");
|
|
7
8
|
group.option("--store-token <profile>", "keep a returned authentication token only in this bot profile's OS keyring; never print it");
|
|
8
9
|
for (const operation of api.operations) {
|
|
9
10
|
const binding = operation.binding.kind === "http"
|
|
10
11
|
? `${operation.binding.method} ${operation.binding.path}`
|
|
11
12
|
: operation.binding.name;
|
|
12
|
-
const command = new Command(operation.command)
|
|
13
|
+
const command = annotate(new Command(operation.command), {
|
|
14
|
+
effects: operation.effect === "read" ? REMOTE_READ : REMOTE_WRITE,
|
|
15
|
+
}).description(`${operation.summary ?? operation.id} — ${operation.effect} (${binding})`);
|
|
13
16
|
for (const parameter of operation.parameters) {
|
|
14
17
|
if (parameter.sensitive)
|
|
15
18
|
continue;
|
|
@@ -2,7 +2,7 @@ import { CliError, pathsAreOverridden } from "@wirecat/cli-base";
|
|
|
2
2
|
import { annotate } from "@wirecat/cli-base/commands";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { envName } from "../ports/app.js";
|
|
5
|
-
import { AUTHORITY_KEY } from "../sends/permissions.js";
|
|
5
|
+
import { AUTHORITY_KEY, BOT_API_KEY } from "../sends/permissions.js";
|
|
6
6
|
import { loadSnakeCaseConfig } from "./config-keys.js";
|
|
7
7
|
import { baseContext, environmentOf } from "./context.js";
|
|
8
8
|
import { grantsAuthority, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
|
|
@@ -96,7 +96,7 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
|
|
|
96
96
|
throw new CliError("permission_error", `this process is locked to profile ${settings.profile} (${lock}) — ${setting === "default_profile" ? setting : "--defaults"} changes other profiles`);
|
|
97
97
|
}
|
|
98
98
|
if (environmentOf(this).agent)
|
|
99
|
-
|
|
99
|
+
refuseGuardedKey(setting, action === "set" ? String(given) : undefined);
|
|
100
100
|
if (action === "set")
|
|
101
101
|
refuseUnknownKey(this, setting, String(given), permissionKey);
|
|
102
102
|
const saved = config.changeSetting(settings.configPath, {
|
|
@@ -137,10 +137,11 @@ export const refuseUnknownKey = (command, setting, value, permissionKey) => {
|
|
|
137
137
|
}
|
|
138
138
|
};
|
|
139
139
|
/**
|
|
140
|
-
* An agent may hold `mcp.authority` and change other settings, but never the key that let it in: on any
|
|
141
|
-
* the defaults, or by replacing `permissions` whole. Only the owner, in a terminal, turns it on or off
|
|
140
|
+
* An agent may hold `mcp.authority` or `bot.api` and change other settings, but never the key that let it in: on any
|
|
141
|
+
* profile, in the defaults, or by replacing `permissions` whole. Only the owner, in a terminal, turns it on or off
|
|
142
|
+
* (NEED-1662, NEED-1679).
|
|
142
143
|
*/
|
|
143
|
-
const
|
|
144
|
+
const refuseGuardedKey = (setting, value) => {
|
|
144
145
|
const keys = setting.startsWith("permissions.")
|
|
145
146
|
? [setting.slice("permissions.".length)]
|
|
146
147
|
: setting === "permissions"
|
|
@@ -148,9 +149,14 @@ const refuseAuthorityKey = (setting, value) => {
|
|
|
148
149
|
? ["mcp"]
|
|
149
150
|
: objectKeys(value)
|
|
150
151
|
: [];
|
|
151
|
-
|
|
152
|
+
const guarded = keys.some((key) => key === BOT_API_KEY || key.startsWith(`${BOT_API_KEY}.`))
|
|
153
|
+
? BOT_API_KEY
|
|
154
|
+
: keys.some((key) => key === "mcp" || key.startsWith("mcp."))
|
|
155
|
+
? AUTHORITY_KEY
|
|
156
|
+
: undefined;
|
|
157
|
+
if (!guarded)
|
|
152
158
|
return;
|
|
153
|
-
throw new CliError("permission_error", `an agent cannot change permissions.${
|
|
159
|
+
throw new CliError("permission_error", `an agent cannot change permissions.${guarded} — the owner sets it in a terminal`, { permission: guarded });
|
|
154
160
|
};
|
|
155
161
|
/** The keys of a `permissions` object given whole; anything else is left to the schema to refuse. */
|
|
156
162
|
const objectKeys = (value) => {
|
package/dist/cli/context.d.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface AgentRun {
|
|
|
87
87
|
overrides?: readonly string[];
|
|
88
88
|
/** The server's levels allow `mcp.authority`: a command that changes what a later run may do may run. */
|
|
89
89
|
authority?: boolean;
|
|
90
|
+
/** The server's levels allow `bot.api`: a raw Bot API call, past the send guard, may run. */
|
|
91
|
+
botApi?: boolean;
|
|
90
92
|
}
|
|
91
93
|
export interface RelayedRun {
|
|
92
94
|
/** The profile serve holds; a command for another is refused before it touches the connection. */
|
package/dist/cli/program.js
CHANGED
|
@@ -11,7 +11,7 @@ import { failureContext } from "../errors/reasons.js";
|
|
|
11
11
|
import { envName, readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
|
|
12
12
|
import { recorded, wasSettled } from "../runs/recording.js";
|
|
13
13
|
import { processRun, useAsProcessRun } from "../runs/scope.js";
|
|
14
|
-
import { AUTHORITY_KEY, keyForCommand } from "../sends/permissions.js";
|
|
14
|
+
import { AUTHORITY_KEY, BOT_API_KEY, isBotApi, keyForCommand } from "../sends/permissions.js";
|
|
15
15
|
import { logParsedArgv, logVersion } from "./argv-log.js";
|
|
16
16
|
import { commandPathOf } from "./command-contract.js";
|
|
17
17
|
import { outputFor, provide } from "./context.js";
|
|
@@ -258,6 +258,10 @@ const runOnce = async (argv, definition, options) => {
|
|
|
258
258
|
throw new CliError("permission_error", `\`${command} ${path.join(" ")}\` changes what a later run may do, and this profile keeps that from agents ` +
|
|
259
259
|
`(${AUTHORITY_KEY}) — the owner turns it on in a terminal: ` +
|
|
260
260
|
`\`${command} <profile> config set${path[0] === "bot" ? " --bot" : ""} permissions.${AUTHORITY_KEY} allow\``, { permission: AUTHORITY_KEY, sent: false });
|
|
261
|
+
if (options.agent && options.agent.botApi !== true && isBotApi(path))
|
|
262
|
+
throw new CliError("permission_error", `\`${command} ${path.join(" ")}\` calls the Bot API directly, past the send guard and the recipient list, ` +
|
|
263
|
+
`and this profile keeps that from agents (${BOT_API_KEY}) — the owner turns it on in a terminal: ` +
|
|
264
|
+
`\`${command} <profile> config set --bot permissions.${BOT_API_KEY} allow\``, { permission: BOT_API_KEY, sent: false });
|
|
261
265
|
const { lifetime, cooperative: stopsAtItsPace } = metaOf(action);
|
|
262
266
|
const persistent = lifetime === "persistent";
|
|
263
267
|
listens = persistent;
|
package/dist/mcp/agent.js
CHANGED
|
@@ -6,7 +6,7 @@ import { VOCABULARY } from "@wirecat/cli-base/standards/tables";
|
|
|
6
6
|
import * as v from "valibot";
|
|
7
7
|
import { changesMessenger } from "../cli/effects.js";
|
|
8
8
|
import { lentConnection } from "../cli/messenger/serve-relay.js";
|
|
9
|
-
import { AUTHORITY_KEY, authorityAllowed, keyForCommand, levelFor, readKeysForCommand, } from "../sends/permissions.js";
|
|
9
|
+
import { AUTHORITY_KEY, authorityAllowed, botApiAllowed, isBotApi, keyForCommand, levelFor, readKeysForCommand, } from "../sends/permissions.js";
|
|
10
10
|
const TYPES = new Map(VOCABULARY.flags.map((flag) => [flag.name, flag.type]));
|
|
11
11
|
// `<name|id>` is one argument: its field is `name_or_id`.
|
|
12
12
|
const snake = (name) => name.replaceAll("|", "_or_").replace(/[^a-z0-9]+/gi, "_");
|
|
@@ -150,7 +150,8 @@ export const toolsFromCommands = (commands, options = {}) => {
|
|
|
150
150
|
info.mcp !== false &&
|
|
151
151
|
info.lifetime === undefined &&
|
|
152
152
|
!(readOnly && (changesMessenger(info) || authority?.(info.path))) &&
|
|
153
|
-
(levels === undefined ||
|
|
153
|
+
(levels === undefined ||
|
|
154
|
+
(refusal(info, levels, keyOf, authority) === undefined && (!isBotApi(info.path) || botApiAllowed(levels)))))
|
|
154
155
|
.map((info) => ({
|
|
155
156
|
command: info.path.slice(root.length).join(" "),
|
|
156
157
|
path: info.path,
|
|
@@ -230,6 +231,7 @@ export const runCommandForAgent = async (program, path, input, ctx) => {
|
|
|
230
231
|
},
|
|
231
232
|
...(ctx.overrides?.length ? { overrides: ctx.overrides } : {}),
|
|
232
233
|
authority: levels !== undefined && authorityAllowed(levels),
|
|
234
|
+
botApi: levels !== undefined && botApiAllowed(levels),
|
|
233
235
|
},
|
|
234
236
|
// Opened only when the command asks: a read the store answers never connects.
|
|
235
237
|
...(lend
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The Hunspell dictionary that tells a real word from a garbled one, for each language the store stems. */
|
|
2
|
+
export declare const DICTIONARIES: {
|
|
3
|
+
readonly russian: "dictionary-ru";
|
|
4
|
+
readonly english: "dictionary-en";
|
|
5
|
+
readonly spanish: "dictionary-es";
|
|
6
|
+
};
|
|
7
|
+
export type DictionaryLanguage = keyof typeof DICTIONARIES;
|
|
8
|
+
/**
|
|
9
|
+
* `known`: whether a folded word the index stores is real in one of `languages` that write in its alphabet.
|
|
10
|
+
* `covers`: whether any of them does; without one, a garbled spelling cannot be told from a real word.
|
|
11
|
+
*/
|
|
12
|
+
export declare const knownWord: (languages: readonly DictionaryLanguage[]) => {
|
|
13
|
+
covers: (word: string) => boolean;
|
|
14
|
+
known: (word: string) => boolean;
|
|
15
|
+
};
|
|
16
|
+
/** The dictionaries of the store's stemmers: Russian for Cyrillic, each named Latin stemmer for Latin. */
|
|
17
|
+
export declare const dictionaryLanguages: (stemmers: {
|
|
18
|
+
cyrillic: string;
|
|
19
|
+
latin: string;
|
|
20
|
+
}) => DictionaryLanguage[];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fold } from "../store/normalize.js";
|
|
3
|
+
/** The Hunspell dictionary that tells a real word from a garbled one, for each language the store stems. */
|
|
4
|
+
export const DICTIONARIES = { russian: "dictionary-ru", english: "dictionary-en", spanish: "dictionary-es" };
|
|
5
|
+
/** `fold`, without Unicode normalisation for plain Russian letters: it would double the time Russian loads in. */
|
|
6
|
+
const folded = (word) => /^[а-яё'-]*$/.test(word) ? word.replaceAll("ё", "е").replaceAll("й", "и") : fold(word);
|
|
7
|
+
const loaded = new Map();
|
|
8
|
+
/**
|
|
9
|
+
* Hunspell's own check, an affix at a time, instead of nspell's: nspell expands Russian's 146,269 entries into
|
|
10
|
+
* 1.44 million forms (1.4 s, 172 MB) where this loads in about 0.2 s. The index stores words folded as `normalize`
|
|
11
|
+
* folds them, so entries, affixes and conditions are folded too; a condition such as `[^ейоь]` then refuses и as
|
|
12
|
+
* well, which changed none of 12,769 sample words. Continuation classes and compounds are not followed: a word
|
|
13
|
+
* only they make counts as unknown and may widen.
|
|
14
|
+
*/
|
|
15
|
+
const dictionary = (language) => {
|
|
16
|
+
const cached = loaded.get(language);
|
|
17
|
+
if (cached)
|
|
18
|
+
return cached;
|
|
19
|
+
const file = (name) => readFileSync(new URL(name, import.meta.resolve(DICTIONARIES[language])), "utf8");
|
|
20
|
+
const words = new Map();
|
|
21
|
+
for (const line of file("index.dic").split("\n").slice(1)) {
|
|
22
|
+
const [entry, flags = ""] = line.trim().split("/");
|
|
23
|
+
if (!entry)
|
|
24
|
+
continue;
|
|
25
|
+
// Names and abbreviations are capitalised in the dictionary; the index is lower case.
|
|
26
|
+
const spelled = entry.toLowerCase();
|
|
27
|
+
const key = folded(spelled);
|
|
28
|
+
words.set(key, (words.get(key) ?? "") + flags);
|
|
29
|
+
}
|
|
30
|
+
const suffixes = new Map();
|
|
31
|
+
const prefixes = new Map();
|
|
32
|
+
const crosses = new Set();
|
|
33
|
+
let longest = 0;
|
|
34
|
+
for (const line of file("index.aff").split("\n")) {
|
|
35
|
+
const [kind, flag, strip, add, condition] = line.trim().split(/\s+/);
|
|
36
|
+
if ((kind !== "SFX" && kind !== "PFX") || flag === undefined || strip === undefined || add === undefined)
|
|
37
|
+
continue;
|
|
38
|
+
if (!condition) {
|
|
39
|
+
if (strip === "Y")
|
|
40
|
+
crosses.add(`${kind}${flag}`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const affix = folded(add.split("/")[0].replace(/^0$/, ""));
|
|
44
|
+
const pattern = condition === "." ? "" : folded(condition);
|
|
45
|
+
const into = kind === "SFX" ? suffixes : prefixes;
|
|
46
|
+
const rules = into.get(affix) ?? [];
|
|
47
|
+
rules.push({
|
|
48
|
+
flag,
|
|
49
|
+
strip: strip === "0" ? "" : folded(strip),
|
|
50
|
+
condition: new RegExp(kind === "SFX" ? `${pattern}$` : `^${pattern}`),
|
|
51
|
+
cross: crosses.has(`${kind}${flag}`),
|
|
52
|
+
});
|
|
53
|
+
into.set(affix, rules);
|
|
54
|
+
longest = Math.max(longest, affix.length);
|
|
55
|
+
}
|
|
56
|
+
const made = { words, suffixes, prefixes, longest };
|
|
57
|
+
loaded.set(language, made);
|
|
58
|
+
return made;
|
|
59
|
+
};
|
|
60
|
+
const entry = ({ words }, key, rule, also) => {
|
|
61
|
+
const flags = words.get(key) ?? "";
|
|
62
|
+
return flags.includes(rule.flag) && (also === undefined || flags.includes(also)) && rule.condition.test(key);
|
|
63
|
+
};
|
|
64
|
+
/** A suffix on a dictionary word; `prefix` is the flag of a prefix already taken off, which both must allow. */
|
|
65
|
+
const suffixed = (found, word, prefix) => {
|
|
66
|
+
for (let cut = 0; cut <= Math.min(word.length - 1, found.longest); cut++)
|
|
67
|
+
for (const rule of found.suffixes.get(word.slice(word.length - cut)) ?? [])
|
|
68
|
+
if ((prefix === undefined || rule.cross) &&
|
|
69
|
+
entry(found, word.slice(0, word.length - cut) + rule.strip, rule, prefix))
|
|
70
|
+
return true;
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
const spelled = (language, word) => {
|
|
74
|
+
const found = dictionary(language);
|
|
75
|
+
if (found.words.has(word) || suffixed(found, word))
|
|
76
|
+
return true;
|
|
77
|
+
for (let cut = 1; cut <= Math.min(word.length - 1, found.longest); cut++)
|
|
78
|
+
for (const rule of found.prefixes.get(word.slice(0, cut)) ?? []) {
|
|
79
|
+
const rest = rule.strip + word.slice(cut);
|
|
80
|
+
if (entry(found, rest, rule) || (rule.cross && suffixed(found, rest, rule.flag)))
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
};
|
|
85
|
+
const CYRILLIC = /\p{Script=Cyrillic}/u;
|
|
86
|
+
const SCRIPT_OF = {
|
|
87
|
+
russian: "cyrillic",
|
|
88
|
+
english: "latin",
|
|
89
|
+
spanish: "latin",
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* `known`: whether a folded word the index stores is real in one of `languages` that write in its alphabet.
|
|
93
|
+
* `covers`: whether any of them does; without one, a garbled spelling cannot be told from a real word.
|
|
94
|
+
*/
|
|
95
|
+
export const knownWord = (languages) => {
|
|
96
|
+
const byScript = (word) => {
|
|
97
|
+
const script = CYRILLIC.test(word) ? "cyrillic" : "latin";
|
|
98
|
+
return languages.filter((language) => SCRIPT_OF[language] === script);
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
covers: (word) => byScript(word).length > 0,
|
|
102
|
+
known: (word) => byScript(word).some((language) => spelled(language, word)),
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
/** The dictionaries of the store's stemmers: Russian for Cyrillic, each named Latin stemmer for Latin. */
|
|
106
|
+
export const dictionaryLanguages = (stemmers) => [stemmers.cyrillic, ...stemmers.latin.split(",")].filter((name) => Object.hasOwn(DICTIONARIES, name));
|
|
@@ -21,6 +21,15 @@ export declare const DEFAULT_PERMISSIONS: Readonly<Record<PermissionKey, Level>>
|
|
|
21
21
|
export declare const AUTHORITY_KEY: PermissionKey;
|
|
22
22
|
/** Whether these levels let an agent run a command that changes what a later run may do; `ask` does, as for any write. */
|
|
23
23
|
export declare const authorityAllowed: (levels: Readonly<Record<PermissionKey, Level>>) => boolean;
|
|
24
|
+
/** Lets an MCP server offer `bot api`, whose calls skip the send guard and the bot's recipient list (NEED-1679). */
|
|
25
|
+
export declare const BOT_API_KEY: PermissionKey;
|
|
26
|
+
export declare const isBotApi: (path: readonly string[]) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Deny by default for an MCP server only: the key stays out of `DEFAULT_PERMISSIONS` because the command line's
|
|
29
|
+
* `bot api` is checked per operation (`bot.api.<operation>`), and a default there would deny it for the owner too.
|
|
30
|
+
* A broader `bot: allow` does not lift it; `ask` goes ahead over MCP, as for any write.
|
|
31
|
+
*/
|
|
32
|
+
export declare const botApiAllowed: (levels: Readonly<Record<PermissionKey, Level>>) => boolean;
|
|
24
33
|
/** The resources at the top of the command tree, which `readOnly` and `allow` turn read-only as a whole. */
|
|
25
34
|
export declare const RESOURCES: readonly ["messages", "reactions", "polls", "topics", "chats", "contacts", "account", "bot", "conversations", "tags", "mentions", "search", "searches", "tasks", "replies", "attachments", "stats", "store", "metadata", "mcp", "server", "models", "schedule", "doctor", "images"];
|
|
26
35
|
/** The command path an old `allow` word stood for, as one key — what an MCP tool's `permission` is checked against. */
|
|
@@ -107,6 +107,18 @@ export const authorityAllowed = (levels) => {
|
|
|
107
107
|
const { level } = levelFor(levels, AUTHORITY_KEY);
|
|
108
108
|
return level === "allow" || level === "ask";
|
|
109
109
|
};
|
|
110
|
+
/** Lets an MCP server offer `bot api`, whose calls skip the send guard and the bot's recipient list (NEED-1679). */
|
|
111
|
+
export const BOT_API_KEY = "bot.api";
|
|
112
|
+
export const isBotApi = (path) => path[0] === "bot" && path[1] === "api";
|
|
113
|
+
/**
|
|
114
|
+
* Deny by default for an MCP server only: the key stays out of `DEFAULT_PERMISSIONS` because the command line's
|
|
115
|
+
* `bot api` is checked per operation (`bot.api.<operation>`), and a default there would deny it for the owner too.
|
|
116
|
+
* A broader `bot: allow` does not lift it; `ask` goes ahead over MCP, as for any write.
|
|
117
|
+
*/
|
|
118
|
+
export const botApiAllowed = (levels) => {
|
|
119
|
+
const { level } = levelFor(levels, BOT_API_KEY, { ...DEFAULT_PERMISSIONS, [BOT_API_KEY]: "deny" });
|
|
120
|
+
return level === "allow" || level === "ask";
|
|
121
|
+
};
|
|
110
122
|
/** The resources at the top of the command tree, which `readOnly` and `allow` turn read-only as a whole. */
|
|
111
123
|
export const RESOURCES = [
|
|
112
124
|
"messages",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { dictionaryLanguages, knownWord } from "../../search/dictionary.js";
|
|
1
2
|
import { isNumberWord, widenWords } from "../../search/garbled.js";
|
|
2
|
-
import { knownRussianWord } from "../../search/russian-dictionary.js";
|
|
3
3
|
import { createStemmer, DEFAULT_STEMMERS, STEM_SEPARATOR } from "../../search/stem.js";
|
|
4
4
|
import { normalize } from "../normalize.js";
|
|
5
5
|
import { ftsMatch, quoted, SPEECH, stemMatch } from "./lucene-fields.js";
|
|
@@ -9,6 +9,13 @@ const predicates = (node) => node.kind === "predicate" ? [node] : node.clauses.f
|
|
|
9
9
|
const readsTranscripts = (node) => (node.operator === "term" || node.operator === "phrase") &&
|
|
10
10
|
/[\p{L}\p{N}]/u.test(normalize(node.value)) &&
|
|
11
11
|
(node.field === "transcript" || (node.implicit === true && (node.field === "text" || node.field === "exact")));
|
|
12
|
+
/** `isNumberWord` knows Russian numbers only; a widened number is a wrong fact in any language. */
|
|
13
|
+
const LATIN_NUMBERS = new Set(`zero one two three four five six seven eight nine ten eleven twelve twenty thirty forty fifty sixty seventy
|
|
14
|
+
eighty ninety hundred thousand million billion first second third fourth fifth sixth seventh eighth ninth tenth
|
|
15
|
+
half quarter cero uno una dos tres cuatro cinco seis siete ocho nueve diez once doce veinte treinta cuarenta
|
|
16
|
+
cincuenta sesenta setenta ochenta noventa cien ciento mil millon primero segundo tercero cuarto quinto medio`.split(/\s+/));
|
|
17
|
+
/** A word widens when its alphabet has a dictionary to tell a garbled spelling from another real word. */
|
|
18
|
+
const widens = (word, covered) => covered(word) && !isNumberWord(word) && !LATIN_NUMBERS.has(word);
|
|
12
19
|
/**
|
|
13
20
|
* Each leaf that reads transcripts, with what it matches there. A transcript always widens, not only when nothing
|
|
14
21
|
* matched as typed text does: a correct and a garbled spelling sit in one chat. Only a single word widens —
|
|
@@ -20,7 +27,9 @@ export const transcriptTerms = (database, root, item) => {
|
|
|
20
27
|
const saved = savedStemmers(database);
|
|
21
28
|
if (item !== "message" || !leaves.length || saved === null)
|
|
22
29
|
return terms;
|
|
23
|
-
const
|
|
30
|
+
const stemmers = saved ?? DEFAULT_STEMMERS;
|
|
31
|
+
const stemmer = createStemmer(stemmers);
|
|
32
|
+
const dictionary = knownWord(dictionaryLanguages(stemmers));
|
|
24
33
|
const stemsOf = (word) => [...new Set(stemmer.explain(word).map((part) => part.stem))];
|
|
25
34
|
const widening = [];
|
|
26
35
|
for (const [node, one] of terms) {
|
|
@@ -31,8 +40,9 @@ export const transcriptTerms = (database, root, item) => {
|
|
|
31
40
|
if (stems)
|
|
32
41
|
Object.assign(one, { stems: node.slop === undefined ? stems : `(${stems}) NOT ${quoted(STEM_SEPARATOR)}` });
|
|
33
42
|
const words = normalize(node.value).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
const [word] = words;
|
|
44
|
+
if (node.operator === "term" && word !== undefined && words.length === 1 && widens(word, dictionary.covers))
|
|
45
|
+
widening.push({ node, literal: word, stems: stemsOf(node.value) });
|
|
36
46
|
}
|
|
37
47
|
if (!widening.length)
|
|
38
48
|
return terms;
|
|
@@ -40,7 +50,7 @@ export const transcriptTerms = (database, root, item) => {
|
|
|
40
50
|
.prepare("SELECT term, doc FROM transcript_words_vocab WHERE col = 'normalized_text'")
|
|
41
51
|
.all()
|
|
42
52
|
.map((row) => ({ term: String(row.term), docs: Number(row.doc) }));
|
|
43
|
-
widenWords(widening, vocabulary, stemsOf,
|
|
53
|
+
widenWords(widening, vocabulary, stemsOf, dictionary.known).forEach((words, at) => {
|
|
44
54
|
const leaf = widening[at];
|
|
45
55
|
if (leaf)
|
|
46
56
|
Object.assign(terms.get(leaf.node), { widened: words });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.402.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",
|
|
@@ -173,6 +173,8 @@
|
|
|
173
173
|
"@xmldom/xmldom": "0.9.12",
|
|
174
174
|
"chardet": "2.2.0",
|
|
175
175
|
"defuddle": "0.19.5",
|
|
176
|
+
"dictionary-en": "4.0.0",
|
|
177
|
+
"dictionary-es": "4.0.0",
|
|
176
178
|
"dictionary-ru": "3.0.0",
|
|
177
179
|
"echarts": "6.1.0",
|
|
178
180
|
"fflate": "0.8.3",
|