@wirecat/cli-core 0.401.0 → 0.403.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 +24 -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/first-run.d.ts +68 -0
- package/dist/cli/first-run.js +224 -0
- package/dist/cli/help/first-screen.d.ts +2 -0
- package/dist/cli/help/first-screen.js +31 -6
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/messenger/context.js +4 -2
- package/dist/cli/messenger/store-maintenance-command.d.ts +10 -0
- package/dist/cli/messenger/store-maintenance-command.js +1 -1
- package/dist/cli/permission-keys.js +2 -2
- package/dist/cli/program.js +16 -1
- package/dist/cli/settings.js +15 -2
- package/dist/cli/setup-answers.d.ts +24 -0
- package/dist/cli/setup-answers.js +47 -0
- package/dist/documents/index.d.ts +1 -0
- package/dist/documents/index.js +1 -0
- 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/guard.d.ts +2 -0
- package/dist/sends/guard.js +8 -3
- package/dist/sends/permissions.d.ts +14 -0
- package/dist/sends/permissions.js +23 -1
- package/dist/services/documents/mirror-watch.d.ts +39 -0
- package/dist/services/documents/mirror-watch.js +155 -0
- package/dist/services/documents/push.d.ts +2 -0
- package/dist/services/documents/push.js +2 -0
- package/dist/store/shared-config.d.ts +4 -0
- package/dist/store/shared-config.js +10 -6
- package/dist/store/sqlite/transcript-query.js +15 -5
- package/package.json +3 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Level } from "../sends/permissions.js";
|
|
2
|
+
/** "allow" leaves every profile as its own configuration says. */
|
|
3
|
+
export declare const PERMISSION_ANSWERS: readonly ["readonly", "ask", "allow"];
|
|
4
|
+
export type PermissionAnswer = (typeof PERMISSION_ANSWERS)[number];
|
|
5
|
+
/**
|
|
6
|
+
* The first-run answers every WireCat CLI shares. Languages live in the store (`search_stemmers`), so only the
|
|
7
|
+
* fact that they were asked is kept here.
|
|
8
|
+
*/
|
|
9
|
+
export interface SetupAnswers {
|
|
10
|
+
languages?: true;
|
|
11
|
+
tools?: true;
|
|
12
|
+
permissions?: PermissionAnswer;
|
|
13
|
+
}
|
|
14
|
+
export declare const QUESTIONS: readonly ["languages", "tools", "permissions"];
|
|
15
|
+
export type Question = (typeof QUESTIONS)[number];
|
|
16
|
+
export declare const readSetupAnswers: (path: string) => SetupAnswers;
|
|
17
|
+
export declare const unanswered: (answers: SetupAnswers) => Question[];
|
|
18
|
+
/** A section written wrongly is replaced; a file that is not JSON at all still refuses, naming itself. */
|
|
19
|
+
export declare const writeSetupAnswers: (path: string, answers: SetupAnswers) => void;
|
|
20
|
+
/**
|
|
21
|
+
* The level no command of any WireCat tool goes above, or `undefined` when the owner chose to leave them as they
|
|
22
|
+
* are. A file that cannot be read keeps everything read-only: reads still work, and `setup` writes it again.
|
|
23
|
+
*/
|
|
24
|
+
export declare const permissionCeiling: (path: string) => Level | undefined;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { readSharedSection, writeSharedSection } from "../store/shared-config.js";
|
|
3
|
+
/** "allow" leaves every profile as its own configuration says. */
|
|
4
|
+
export const PERMISSION_ANSWERS = ["readonly", "ask", "allow"];
|
|
5
|
+
export const QUESTIONS = ["languages", "tools", "permissions"];
|
|
6
|
+
const SECTION = "setup";
|
|
7
|
+
export const readSetupAnswers = (path) => {
|
|
8
|
+
const section = readSharedSection(path, SECTION);
|
|
9
|
+
if (section === undefined)
|
|
10
|
+
return {};
|
|
11
|
+
const answers = section;
|
|
12
|
+
if (section === null ||
|
|
13
|
+
typeof section !== "object" ||
|
|
14
|
+
Array.isArray(section) ||
|
|
15
|
+
(answers.languages !== undefined && answers.languages !== true) ||
|
|
16
|
+
(answers.tools !== undefined && answers.tools !== true) ||
|
|
17
|
+
(answers.permissions !== undefined && !PERMISSION_ANSWERS.includes(answers.permissions)))
|
|
18
|
+
throw new CliError("configuration_error", `${path}: ${SECTION} holds what setup asked — languages: true, tools: true, permissions: ${PERMISSION_ANSWERS.join(" | ")}`);
|
|
19
|
+
return answers;
|
|
20
|
+
};
|
|
21
|
+
export const unanswered = (answers) => QUESTIONS.filter((question) => answers[question] === undefined);
|
|
22
|
+
/** A section written wrongly is replaced; a file that is not JSON at all still refuses, naming itself. */
|
|
23
|
+
export const writeSetupAnswers = (path, answers) => {
|
|
24
|
+
let kept = {};
|
|
25
|
+
try {
|
|
26
|
+
kept = readSetupAnswers(path);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (!(error instanceof CliError) || !error.message.includes(`${SECTION} holds`))
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
writeSharedSection(path, SECTION, { ...kept, ...answers });
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The level no command of any WireCat tool goes above, or `undefined` when the owner chose to leave them as they
|
|
36
|
+
* are. A file that cannot be read keeps everything read-only: reads still work, and `setup` writes it again.
|
|
37
|
+
*/
|
|
38
|
+
export const permissionCeiling = (path) => {
|
|
39
|
+
let answer;
|
|
40
|
+
try {
|
|
41
|
+
answer = readSetupAnswers(path).permissions;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "readonly";
|
|
45
|
+
}
|
|
46
|
+
return answer === "allow" ? undefined : answer;
|
|
47
|
+
};
|
|
@@ -3,6 +3,7 @@ export { type Complete, extractMemories } from "../services/documents/extraction
|
|
|
3
3
|
export { documentIngestion, type IngestionOptions } from "../services/documents/ingestion.js";
|
|
4
4
|
export { type DocumentKnowledge, documentKnowledge } from "../services/documents/knowledge.js";
|
|
5
5
|
export { blocksMarkdown, inlineMarkdown, spliceMarkdown } from "../services/documents/markdown-splice.js";
|
|
6
|
+
export { type MirrorTrigger, type MirrorWatchEvent, type MirrorWatchOptions, watchMirrors, } from "../services/documents/mirror-watch.js";
|
|
6
7
|
export { conflictReport, fileMirrorState, type Mirror, type MirrorSyncOptions, mirrorId, mirrorStatus, syncMirrors, } from "../services/documents/mirrors.js";
|
|
7
8
|
export type { SyncOptions } from "../services/documents/ports.js";
|
|
8
9
|
export { extractDocumentMemories, type ImportedSource, type ProposalOptions } from "../services/documents/proposals.js";
|
package/dist/documents/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { extractMemories } from "../services/documents/extraction.js";
|
|
|
3
3
|
export { documentIngestion } from "../services/documents/ingestion.js";
|
|
4
4
|
export { documentKnowledge } from "../services/documents/knowledge.js";
|
|
5
5
|
export { blocksMarkdown, inlineMarkdown, spliceMarkdown } from "../services/documents/markdown-splice.js";
|
|
6
|
+
export { watchMirrors, } from "../services/documents/mirror-watch.js";
|
|
6
7
|
export { conflictReport, fileMirrorState, mirrorId, mirrorStatus, syncMirrors, } from "../services/documents/mirrors.js";
|
|
7
8
|
export { extractDocumentMemories } from "../services/documents/proposals.js";
|
|
8
9
|
export { planPush, pushDocuments, scanLocal, } from "../services/documents/push.js";
|
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));
|
package/dist/sends/guard.d.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface SendGuardOptions {
|
|
|
71
71
|
now?: () => Date;
|
|
72
72
|
}
|
|
73
73
|
/** A write the profile's level refuses, worded alike by the guard and by the check before connecting. */
|
|
74
|
+
/** A write the first-run answer stops, whatever the profile allows. */
|
|
75
|
+
export declare const ceilingRefusal: (key: PermissionKey, command: string) => CliError;
|
|
74
76
|
export declare const levelRefusal: (refused: {
|
|
75
77
|
profile: string;
|
|
76
78
|
command: string;
|
package/dist/sends/guard.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CliError } from "@wirecat/cli-base";
|
|
|
3
3
|
import { MESSENGER_CHANGE_REFUSAL, readOnlyRefusal } from "../ports/app.js";
|
|
4
4
|
import { FloodMemory, floodPathFor, holdEnd } from "./flood.js";
|
|
5
5
|
import { SendJournal, sendsPathFor, } from "./journal.js";
|
|
6
|
-
import { keyForWrite, levelFor, skipFlagFor } from "./permissions.js";
|
|
6
|
+
import { CEILING_KEY, keyForWrite, levelFor, skipFlagFor } from "./permissions.js";
|
|
7
7
|
import { RecipientList, recipientsPathFor } from "./recipients.js";
|
|
8
8
|
const HOUR_MS = 60 * 60 * 1000;
|
|
9
9
|
/**
|
|
@@ -51,8 +51,13 @@ const weightOf = ({ kind = "message", action, count, people }) => kind === "dele
|
|
|
51
51
|
/** A scheduled message counts in the hour the provider sends it. */
|
|
52
52
|
const timeOf = (entry) => Date.parse(entry.scheduledFor ?? entry.at);
|
|
53
53
|
/** A write the profile's level refuses, worded alike by the guard and by the check before connecting. */
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/** A write the first-run answer stops, whatever the profile allows. */
|
|
55
|
+
export const ceilingRefusal = (key, command) => new CliError("permission_error", `${key} is not allowed: setup's answer keeps every WireCat tool read-only; ` +
|
|
56
|
+
`the owner changes it at a terminal with \`${command} setup\``, { permission: key });
|
|
57
|
+
export const levelRefusal = (refused, fix = `${refused.command} ${refused.profile} config set permissions.${refused.key} allow`) => refused.named === CEILING_KEY
|
|
58
|
+
? ceilingRefusal(refused.key, refused.command)
|
|
59
|
+
: new CliError("permission_error", `profile ${refused.profile} does not let ${refused.key} write (permissions.${refused.named} is ${refused.level}, ` +
|
|
60
|
+
`from the ${refused.permissionSources[refused.named ?? ""] ?? "default"}); to allow it: ${fix}`, { permission: refused.key });
|
|
56
61
|
/**
|
|
57
62
|
* These stop a model that was talked into sending by a message it read. They do **not** stop an
|
|
58
63
|
* agent that edits the configuration itself — that needs a boundary outside this process
|
|
@@ -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. */
|
|
@@ -34,6 +43,11 @@ export declare const layerPermissions: (layers: readonly [string, Readonly<Recor
|
|
|
34
43
|
levels: Record<string, "allow" | "ask" | "deny" | "readonly">;
|
|
35
44
|
sources: Record<string, string>;
|
|
36
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* The owner's first-run answer, held among a profile's levels: no key of any profile goes above it. Only `setup`
|
|
48
|
+
* at a terminal writes it, so an agent cannot lift it with `config set`.
|
|
49
|
+
*/
|
|
50
|
+
export declare const CEILING_KEY = "*";
|
|
37
51
|
/**
|
|
38
52
|
* The most specific key the owner set wins. A built-in default (`DEFAULT_PERMISSIONS`) only ever
|
|
39
53
|
* tightens: against a broader key of the owner's, the stricter of the two holds — so
|
|
@@ -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",
|
|
@@ -195,6 +207,11 @@ export const layerPermissions = (layers) => {
|
|
|
195
207
|
}
|
|
196
208
|
return { levels, sources };
|
|
197
209
|
};
|
|
210
|
+
/**
|
|
211
|
+
* The owner's first-run answer, held among a profile's levels: no key of any profile goes above it. Only `setup`
|
|
212
|
+
* at a terminal writes it, so an agent cannot lift it with `config set`.
|
|
213
|
+
*/
|
|
214
|
+
export const CEILING_KEY = "*";
|
|
198
215
|
const ALLOWED = { level: "allow", key: null };
|
|
199
216
|
const ORDER = { deny: 0, readonly: 1, ask: 2, allow: 3 };
|
|
200
217
|
const nearest = (levels, key) => {
|
|
@@ -218,9 +235,14 @@ const FORMER_KEYS = { "chats.unmute": "chats.mute" };
|
|
|
218
235
|
* A path nothing names is allowed.
|
|
219
236
|
*/
|
|
220
237
|
export const levelFor = (permissions, key, defaults = DEFAULT_PERMISSIONS) => {
|
|
238
|
+
const found = profileLevel(permissions, key, defaults);
|
|
239
|
+
const ceiling = permissions[CEILING_KEY];
|
|
240
|
+
return ceiling && ORDER[ceiling] < ORDER[found.level] ? { level: ceiling, key: CEILING_KEY } : found;
|
|
241
|
+
};
|
|
242
|
+
const profileLevel = (permissions, key, defaults) => {
|
|
221
243
|
const former = FORMER_KEYS[key];
|
|
222
244
|
if (former !== undefined && permissions[key] === undefined)
|
|
223
|
-
return
|
|
245
|
+
return profileLevel(permissions, former, defaults);
|
|
224
246
|
const own = nearest(permissions, key);
|
|
225
247
|
const builtIn = nearest(defaults, key);
|
|
226
248
|
if (!builtIn || (own && own.depth >= builtIn.depth))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import type { PushStatePort } from "../../documents/push-model.js";
|
|
3
|
+
import { type Mirror } from "./mirrors.js";
|
|
4
|
+
/** What started a cycle: the first run, a local save, a remote change, the periodic local check, or a full run. */
|
|
5
|
+
export type MirrorTrigger = "start" | "local" | "remote" | "check" | "full";
|
|
6
|
+
export type MirrorWatchEvent<T> = {
|
|
7
|
+
triggers: MirrorTrigger[];
|
|
8
|
+
mirrors: string[];
|
|
9
|
+
result: T;
|
|
10
|
+
} | {
|
|
11
|
+
triggers: MirrorTrigger[];
|
|
12
|
+
mirrors: string[];
|
|
13
|
+
error: CliError;
|
|
14
|
+
};
|
|
15
|
+
export interface MirrorWatchOptions<S> {
|
|
16
|
+
mirrors: Mirror[];
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
kindOf(path: string): string | undefined;
|
|
19
|
+
stateOf(mirror: Mirror): PushStatePort<S>;
|
|
20
|
+
/** Ids of the mirrors whose remote files changed since the last call, or `all` when the provider cannot tell. */
|
|
21
|
+
remoteChanges?(signal: AbortSignal): Promise<string[] | "all">;
|
|
22
|
+
pollMs?: number;
|
|
23
|
+
quietMs?: number;
|
|
24
|
+
/** How often local files are compared with the state, for file events the system dropped. */
|
|
25
|
+
checkMs?: number;
|
|
26
|
+
/** How often every mirror is synced whatever the triggers said, for remote changes the provider listed late. */
|
|
27
|
+
fullMs?: number;
|
|
28
|
+
watchFolder?(root: string, changed: () => void): () => void;
|
|
29
|
+
sleep?(milliseconds: number, signal: AbortSignal): Promise<void>;
|
|
30
|
+
now?(): number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Keeps mirrors in sync until `signal` aborts: a cycle at start, then one for each mirror whose files were saved
|
|
34
|
+
* (after `quietMs` without another save), whose remote files changed, or whose files differ from the state at the
|
|
35
|
+
* periodic check; every mirror at each full run. A local trigger syncs only when the files differ from the state, so
|
|
36
|
+
* the files a pull writes do not start another cycle. A failed cycle is reported and its mirrors tried again at the
|
|
37
|
+
* next poll; the watch goes on.
|
|
38
|
+
*/
|
|
39
|
+
export declare function watchMirrors<S, T>(sync: (mirrors: Mirror[], signal: AbortSignal) => Promise<T>, options: MirrorWatchOptions<S>): AsyncGenerator<MirrorWatchEvent<T>, void, void>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
3
|
+
import { mirrorStatus } from "./mirrors.js";
|
|
4
|
+
import { skippedName } from "./push.js";
|
|
5
|
+
const sleep = (milliseconds, signal) => new Promise((resolve) => {
|
|
6
|
+
const finish = () => {
|
|
7
|
+
clearTimeout(timer);
|
|
8
|
+
signal.removeEventListener("abort", finish);
|
|
9
|
+
resolve();
|
|
10
|
+
};
|
|
11
|
+
const timer = setTimeout(finish, milliseconds);
|
|
12
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
13
|
+
});
|
|
14
|
+
const watchFolder = (root, changed) => {
|
|
15
|
+
try {
|
|
16
|
+
const watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
17
|
+
if (name === null || !name.split(/[\\/]/).some(skippedName))
|
|
18
|
+
changed();
|
|
19
|
+
});
|
|
20
|
+
// A lost watcher only delays a sync until the periodic check; it never stops the watch.
|
|
21
|
+
watcher.on("error", () => watcher.close());
|
|
22
|
+
return () => watcher.close();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return () => { };
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const failure = (error) => isCliError(error) ? error : new CliError("provider_error", "the sync cycle failed");
|
|
29
|
+
/**
|
|
30
|
+
* Keeps mirrors in sync until `signal` aborts: a cycle at start, then one for each mirror whose files were saved
|
|
31
|
+
* (after `quietMs` without another save), whose remote files changed, or whose files differ from the state at the
|
|
32
|
+
* periodic check; every mirror at each full run. A local trigger syncs only when the files differ from the state, so
|
|
33
|
+
* the files a pull writes do not start another cycle. A failed cycle is reported and its mirrors tried again at the
|
|
34
|
+
* next poll; the watch goes on.
|
|
35
|
+
*/
|
|
36
|
+
export async function* watchMirrors(sync, options) {
|
|
37
|
+
const { mirrors, signal } = options;
|
|
38
|
+
const pollMs = options.pollMs ?? 60_000;
|
|
39
|
+
const quietMs = options.quietMs ?? 2_000;
|
|
40
|
+
const checkMs = options.checkMs ?? 300_000;
|
|
41
|
+
const fullMs = options.fullMs ?? 1_800_000;
|
|
42
|
+
const now = options.now ?? Date.now;
|
|
43
|
+
const wait = options.sleep ?? sleep;
|
|
44
|
+
const byId = new Map(mirrors.map((mirror) => [mirror.id, mirror]));
|
|
45
|
+
const saved = new Map();
|
|
46
|
+
const retry = new Set();
|
|
47
|
+
let wake = new AbortController();
|
|
48
|
+
const closers = mirrors.map((mirror) => (options.watchFolder ?? watchFolder)(mirror.path, () => {
|
|
49
|
+
saved.set(mirror.id, now());
|
|
50
|
+
wake.abort();
|
|
51
|
+
}));
|
|
52
|
+
const differs = async (ids) => {
|
|
53
|
+
const chosen = [...ids].flatMap((id) => byId.get(id) ?? []);
|
|
54
|
+
try {
|
|
55
|
+
const items = await mirrorStatus(chosen, options.kindOf, options.stateOf);
|
|
56
|
+
return items.filter((item) => item.create.length || item.update.length).map((item) => item.id);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// An unreadable folder is for the sync to report.
|
|
60
|
+
return chosen.map((mirror) => mirror.id);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const run = async (triggers, ids) => {
|
|
64
|
+
const chosen = mirrors.filter((mirror) => ids.has(mirror.id));
|
|
65
|
+
const names = chosen.map((mirror) => mirror.id);
|
|
66
|
+
try {
|
|
67
|
+
return { triggers, mirrors: names, result: await sync(chosen, signal) };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
for (const id of names)
|
|
71
|
+
retry.add(id);
|
|
72
|
+
return { triggers, mirrors: names, error: failure(error) };
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
if (signal.aborted)
|
|
77
|
+
return;
|
|
78
|
+
const first = await run(["start"], new Set(byId.keys()));
|
|
79
|
+
// A cycle cut short by the stop is the stop, not a failure.
|
|
80
|
+
if (signal.aborted && "error" in first)
|
|
81
|
+
return;
|
|
82
|
+
yield first;
|
|
83
|
+
let nextPoll = now() + pollMs;
|
|
84
|
+
let nextCheck = now() + checkMs;
|
|
85
|
+
let nextFull = now() + fullMs;
|
|
86
|
+
while (!signal.aborted) {
|
|
87
|
+
let due = Math.min(nextPoll, nextCheck, nextFull);
|
|
88
|
+
for (const at of saved.values())
|
|
89
|
+
due = Math.min(due, at + quietMs);
|
|
90
|
+
const until = due - now();
|
|
91
|
+
if (until > 0) {
|
|
92
|
+
await wait(until, AbortSignal.any([signal, wake.signal]));
|
|
93
|
+
if (wake.signal.aborted)
|
|
94
|
+
wake = new AbortController();
|
|
95
|
+
if (signal.aborted)
|
|
96
|
+
return;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const at = now();
|
|
100
|
+
const triggers = [];
|
|
101
|
+
const ids = new Set();
|
|
102
|
+
const add = (trigger, found) => {
|
|
103
|
+
const before = ids.size;
|
|
104
|
+
for (const id of found)
|
|
105
|
+
ids.add(id);
|
|
106
|
+
if (ids.size > before && !triggers.includes(trigger))
|
|
107
|
+
triggers.push(trigger);
|
|
108
|
+
};
|
|
109
|
+
if (at >= nextFull) {
|
|
110
|
+
add("full", byId.keys());
|
|
111
|
+
nextFull = at + fullMs;
|
|
112
|
+
nextCheck = at + checkMs;
|
|
113
|
+
nextPoll = at + pollMs;
|
|
114
|
+
saved.clear();
|
|
115
|
+
retry.clear();
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const settled = [...saved].filter(([, saveAt]) => saveAt + quietMs <= at).map(([id]) => id);
|
|
119
|
+
for (const id of settled)
|
|
120
|
+
saved.delete(id);
|
|
121
|
+
if (settled.length)
|
|
122
|
+
add("local", await differs(settled));
|
|
123
|
+
if (at >= nextCheck) {
|
|
124
|
+
add("check", await differs(byId.keys()));
|
|
125
|
+
nextCheck = at + checkMs;
|
|
126
|
+
}
|
|
127
|
+
if (at >= nextPoll) {
|
|
128
|
+
nextPoll = at + pollMs;
|
|
129
|
+
add("remote", retry);
|
|
130
|
+
retry.clear();
|
|
131
|
+
if (options.remoteChanges)
|
|
132
|
+
try {
|
|
133
|
+
const changed = await options.remoteChanges(signal);
|
|
134
|
+
add("remote", changed === "all" ? byId.keys() : changed.filter((id) => byId.has(id)));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (signal.aborted)
|
|
138
|
+
return;
|
|
139
|
+
yield { triggers: ["remote"], mirrors: [], error: failure(error) };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (signal.aborted || !ids.size)
|
|
144
|
+
continue;
|
|
145
|
+
const event = await run(triggers, ids);
|
|
146
|
+
if (signal.aborted && "error" in event)
|
|
147
|
+
return;
|
|
148
|
+
yield event;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
for (const close of closers)
|
|
153
|
+
close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -60,6 +60,8 @@ export interface PushResult {
|
|
|
60
60
|
};
|
|
61
61
|
stopped?: true;
|
|
62
62
|
}
|
|
63
|
+
/** Hidden files, `node_modules` and editor leftovers: never mirrored, and never a reason to sync. */
|
|
64
|
+
export declare const skippedName: (name: string) => boolean;
|
|
63
65
|
/** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
|
|
64
66
|
export declare const scanLocal: (root: string, kindOf: (path: string) => string | undefined, maxFiles?: number) => Promise<{
|
|
65
67
|
files: LocalDocument[];
|
|
@@ -4,6 +4,8 @@ import { chmod, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { basename, dirname, join, posix } from "node:path";
|
|
5
5
|
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
6
6
|
const SKIPPED = /^(\.|node_modules$|~\$)|(~|\.swp|\.tmp)$/;
|
|
7
|
+
/** Hidden files, `node_modules` and editor leftovers: never mirrored, and never a reason to sync. */
|
|
8
|
+
export const skippedName = (name) => SKIPPED.test(name);
|
|
7
9
|
const hashFile = (path) => new Promise((done, fail) => {
|
|
8
10
|
const hash = createHash("sha256");
|
|
9
11
|
createReadStream(path)
|
|
@@ -12,6 +12,10 @@ export declare const readTaskCategoryEdits: (path: string) => TaskCategoryEdits
|
|
|
12
12
|
export declare const applyTaskCategoryEdits: (base: readonly TaskCategory[], edits: TaskCategoryEdits, path: string) => readonly TaskCategory[];
|
|
13
13
|
/** The edits that turn the default list into `list`, for moving a list an older build saved in the store. */
|
|
14
14
|
export declare const editsFrom: (list: readonly TaskCategory[]) => TaskCategoryEdits;
|
|
15
|
+
/** One section of the file, or `undefined` when the file or the section is missing. */
|
|
16
|
+
export declare const readSharedSection: (path: string, section: string) => unknown;
|
|
17
|
+
/** Writes one section (or drops it, with `undefined`), keeping every other key of the file. */
|
|
18
|
+
export declare const writeSharedSection: (path: string, section: string, value: unknown) => void;
|
|
15
19
|
/** Writes the section (or drops it, with `undefined`), keeping every other key of the file. */
|
|
16
20
|
export declare const writeTaskCategoryEdits: (path: string, edits: TaskCategoryEdits | undefined) => void;
|
|
17
21
|
/** Read again only when the file changed: a sync asks for the list once per message. */
|
|
@@ -39,7 +39,7 @@ const readJson = (path) => {
|
|
|
39
39
|
};
|
|
40
40
|
/** The `task_categories` section, or `undefined` when the file or the section is missing. */
|
|
41
41
|
export const readTaskCategoryEdits = (path) => {
|
|
42
|
-
const section =
|
|
42
|
+
const section = readSharedSection(path, SECTION);
|
|
43
43
|
if (section === undefined)
|
|
44
44
|
return undefined;
|
|
45
45
|
if (section === null || typeof section !== "object" || Array.isArray(section))
|
|
@@ -77,18 +77,22 @@ export const editsFrom = (list) => {
|
|
|
77
77
|
}
|
|
78
78
|
return edits;
|
|
79
79
|
};
|
|
80
|
-
/**
|
|
81
|
-
export const
|
|
80
|
+
/** One section of the file, or `undefined` when the file or the section is missing. */
|
|
81
|
+
export const readSharedSection = (path, section) => readJson(path)?.[section];
|
|
82
|
+
/** Writes one section (or drops it, with `undefined`), keeping every other key of the file. */
|
|
83
|
+
export const writeSharedSection = (path, section, value) => {
|
|
82
84
|
const file = readJson(path) ?? {};
|
|
83
|
-
if (
|
|
84
|
-
delete file[
|
|
85
|
+
if (value === undefined)
|
|
86
|
+
delete file[section];
|
|
85
87
|
else
|
|
86
|
-
file[
|
|
88
|
+
file[section] = value;
|
|
87
89
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
88
90
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
89
91
|
writeFileSync(temporary, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
90
92
|
renameSync(temporary, path);
|
|
91
93
|
};
|
|
94
|
+
/** Writes the section (or drops it, with `undefined`), keeping every other key of the file. */
|
|
95
|
+
export const writeTaskCategoryEdits = (path, edits) => writeSharedSection(path, SECTION, edits === undefined || Object.keys(edits).length === 0 ? undefined : edits);
|
|
92
96
|
/** Read again only when the file changed: a sync asks for the list once per message. */
|
|
93
97
|
export const taskCategoriesReader = (path) => {
|
|
94
98
|
let seen;
|