@vatio-ai/cli 0.37.2 → 0.44.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/bin/vatio.mjs +3 -0
- package/lib/api-client.mjs +36 -19
- package/lib/commands/chat.mjs +17 -8
- package/lib/commands/identity-key.mjs +95 -0
- package/lib/commands/knowledge.mjs +138 -147
- package/lib/commands/support-commands.mjs +26 -2
- package/lib/commands/workspace-admin.mjs +0 -4
- package/lib/help.mjs +7 -4
- package/lib/workspace.mjs +3 -2
- package/package.json +1 -1
package/bin/vatio.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { login, logout } from "../lib/commands/auth.mjs";
|
|
|
18
18
|
import { configCommand, doctor, init, version } from "../lib/commands/misc.mjs";
|
|
19
19
|
import { diff, publish, push, rollback, status, toolsCheck } from "../lib/commands/deploy.mjs";
|
|
20
20
|
import { secrets, tokens, widget } from "../lib/commands/workspace-admin.mjs";
|
|
21
|
+
import { auth } from "../lib/commands/identity-key.mjs";
|
|
21
22
|
import { chat } from "../lib/commands/chat.mjs";
|
|
22
23
|
import { kb } from "../lib/commands/knowledge.mjs";
|
|
23
24
|
import { instagram, whatsapp } from "../lib/commands/channels.mjs";
|
|
@@ -70,6 +71,8 @@ async function main(argv) {
|
|
|
70
71
|
return await tokens(config, args);
|
|
71
72
|
case "widget":
|
|
72
73
|
return await widget(config, args);
|
|
74
|
+
case "auth":
|
|
75
|
+
return await auth(config, args);
|
|
73
76
|
case "chat":
|
|
74
77
|
return await chat(config, args);
|
|
75
78
|
case "kb":
|
package/lib/api-client.mjs
CHANGED
|
@@ -73,6 +73,9 @@ export class ApiClient extends HttpClient {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// --- knowledge bases ------------------------------------------------
|
|
76
|
+
//
|
|
77
|
+
// A base holds entries (markdown) and reads sites (a URL or a pattern). Two
|
|
78
|
+
// nouns, and every call below is one of them.
|
|
76
79
|
|
|
77
80
|
knowledgeBases() {
|
|
78
81
|
return this.get("/knowledge_bases");
|
|
@@ -90,27 +93,38 @@ export class ApiClient extends HttpClient {
|
|
|
90
93
|
return this.delete(`/knowledge_bases/${encode(name)}`);
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
|
|
94
|
-
return this.post(`/knowledge_bases/${encode(
|
|
95
|
-
name,
|
|
96
|
-
site_url: siteUrl,
|
|
97
|
-
include: includes,
|
|
98
|
-
exclude: excludes
|
|
99
|
-
});
|
|
96
|
+
refreshKnowledgeBase(name) {
|
|
97
|
+
return this.post(`/knowledge_bases/${encode(name)}/refresh`, {});
|
|
100
98
|
}
|
|
101
99
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
uploadKnowledgeDocument({ base, filename, content }) {
|
|
105
|
-
return this.post(`/knowledge_bases/${encode(base)}/sources`, { filename, content });
|
|
100
|
+
knowledgeEntry({ base, name }) {
|
|
101
|
+
return this.get(`/knowledge_bases/${encode(base)}/entries/${encode(name)}`);
|
|
106
102
|
}
|
|
107
103
|
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
// Put this markdown under this name, whether or not the entry exists yet --
|
|
105
|
+
// one call for "write" and "rewrite", because from the terminal they are the
|
|
106
|
+
// same sentence.
|
|
107
|
+
writeKnowledgeEntry({ base, name, content }) {
|
|
108
|
+
return this.patch(`/knowledge_bases/${encode(base)}/entries/${encode(name)}`, { content });
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
|
|
113
|
-
return this.
|
|
111
|
+
deleteKnowledgeEntry({ base, name }) {
|
|
112
|
+
return this.delete(`/knowledge_bases/${encode(base)}/entries/${encode(name)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
followKnowledgeSite({ base, url }) {
|
|
116
|
+
return this.post(`/knowledge_bases/${encode(base)}/sites`, { url });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Addressed by the URL you typed rather than by an id you would have to look
|
|
120
|
+
// up first. It travels as a query parameter because a URL does not survive
|
|
121
|
+
// being a path segment.
|
|
122
|
+
unfollowKnowledgeSite({ base, url }) {
|
|
123
|
+
return this.delete(`/knowledge_bases/${encode(base)}/sites?url=${encode(url)}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
refreshKnowledgeSite({ base, url }) {
|
|
127
|
+
return this.post(`/knowledge_bases/${encode(base)}/sites/refresh`, { url });
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
// --- publishable tokens and the widget ------------------------------
|
|
@@ -240,12 +254,15 @@ export class ApiClient extends HttpClient {
|
|
|
240
254
|
return this.post(`/chats/${encode(chatId)}/reset`, { environment });
|
|
241
255
|
}
|
|
242
256
|
|
|
243
|
-
|
|
244
|
-
|
|
257
|
+
// No `view` parameter: the developer token is the developer view, so the
|
|
258
|
+
// API returns one shape and the caller decides how much of it to print.
|
|
259
|
+
// `transcript` prints the visitor's half of it; `debug` prints all of it.
|
|
260
|
+
showChat({ chatId }) {
|
|
261
|
+
return this.get(`/chats/${encode(chatId)}`);
|
|
245
262
|
}
|
|
246
263
|
|
|
247
|
-
chatMessages({ chatId,
|
|
248
|
-
const params = {
|
|
264
|
+
chatMessages({ chatId, after = null, limit = null }) {
|
|
265
|
+
const params = {};
|
|
249
266
|
if (after) params.after = after;
|
|
250
267
|
if (limit) params.limit = limit;
|
|
251
268
|
return this.get(`/chats/${encode(chatId)}/messages`, params);
|
package/lib/commands/chat.mjs
CHANGED
|
@@ -148,15 +148,25 @@ async function converse(config, options, message) {
|
|
|
148
148
|
console.log(formatVisitor(assistant));
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// What a visitor would have seen in a row the developer API always sends in
|
|
152
|
+
// full -- the same three questions the server used to answer behind
|
|
153
|
+
// `view=visitor`: not deleted, said by a person or the agent, and actually
|
|
154
|
+
// carrying words. A tool call with no text answers the last one and is skipped,
|
|
155
|
+
// which is what makes this safe to run over the developer payload.
|
|
156
|
+
const visitorVisible = (entry) =>
|
|
157
|
+
!entry.discarded &&
|
|
158
|
+
["user", "assistant"].includes(entry.role) &&
|
|
159
|
+
String(entry.content ?? "").trim() !== "";
|
|
160
|
+
|
|
151
161
|
// Conversations are asynchronous: post a message, then poll for the reply.
|
|
152
162
|
async function waitForAssistant(api, { chatId, after, timeout, interval = 2 }) {
|
|
153
163
|
const deadline = Date.now() + timeout * 1000;
|
|
154
164
|
for (;;) {
|
|
155
|
-
const payload = await api.chatMessages({ chatId,
|
|
165
|
+
const payload = await api.chatMessages({ chatId, after });
|
|
156
166
|
const messages = Array.isArray(payload.data) ? payload.data : [];
|
|
157
|
-
const assistant = [...messages]
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
const assistant = [...messages]
|
|
168
|
+
.reverse()
|
|
169
|
+
.find((entry) => entry.role === "assistant" && visitorVisible(entry));
|
|
160
170
|
if (assistant) return assistant;
|
|
161
171
|
if (Date.now() >= deadline) fail(`Timed out waiting for assistant reply after ${timeout}s`);
|
|
162
172
|
await sleep(interval * 1000);
|
|
@@ -168,11 +178,10 @@ async function transcript(config, options) {
|
|
|
168
178
|
const chatId = await ensureChat(config, options);
|
|
169
179
|
const payload = await api.chatMessages({
|
|
170
180
|
chatId,
|
|
171
|
-
view: "visitor",
|
|
172
181
|
limit: Math.min(options.last * 6, 200)
|
|
173
182
|
});
|
|
174
183
|
const messages = (Array.isArray(payload.data) ? payload.data : [])
|
|
175
|
-
.filter(
|
|
184
|
+
.filter(visitorVisible)
|
|
176
185
|
.slice(-options.last);
|
|
177
186
|
for (const message of messages) console.log(formatVisitor(message));
|
|
178
187
|
}
|
|
@@ -182,10 +191,10 @@ async function debugChat(config, options) {
|
|
|
182
191
|
const chatId = await ensureChat(config, options);
|
|
183
192
|
|
|
184
193
|
console.log("## Chat status (developer)");
|
|
185
|
-
console.log(JSON.stringify(await api.showChat({ chatId
|
|
194
|
+
console.log(JSON.stringify(await api.showChat({ chatId }), null, 2));
|
|
186
195
|
console.log("");
|
|
187
196
|
console.log(`## Transcript (developer/debug, last ${options.last})`);
|
|
188
|
-
const payload = await api.chatMessages({ chatId,
|
|
197
|
+
const payload = await api.chatMessages({ chatId, limit: options.last });
|
|
189
198
|
for (const message of Array.isArray(payload.data) ? payload.data : []) {
|
|
190
199
|
console.log(`[${message.role}] ${String(message.content ?? "").trim()}`);
|
|
191
200
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `vatio auth --new-key` — the keypair that signs the JWT saying who a visitor is.
|
|
2
|
+
//
|
|
3
|
+
// This exists because asking a developer to authenticate users and then leaving
|
|
4
|
+
// them to work out `openssl genpkey` flags, which half of the pair goes in the
|
|
5
|
+
// workspace, and what the token has to contain is most of the reason the old
|
|
6
|
+
// design grew a JavaScript escape hatch. Everything the platform can settle for
|
|
7
|
+
// them is settled here: the algorithm, the file names, the audience, and the
|
|
8
|
+
// exact claims, printed as code they can paste.
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { fail } from "../support.mjs";
|
|
14
|
+
|
|
15
|
+
const PUBLIC_FILE = "identity.pub";
|
|
16
|
+
const PRIVATE_FILE = "identity.pem";
|
|
17
|
+
|
|
18
|
+
export async function auth(config, args) {
|
|
19
|
+
if (!args.includes("--new-key")) {
|
|
20
|
+
fail("Usage: vatio auth --new-key (generates the keypair that signs your JWT)");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const root = config.workspaceRootRequired();
|
|
24
|
+
const publicPath = join(root, PUBLIC_FILE);
|
|
25
|
+
const privatePath = join(root, PRIVATE_FILE);
|
|
26
|
+
const slug = config.resolveWorkspaceRequired();
|
|
27
|
+
|
|
28
|
+
if (existsSync(publicPath) && !args.includes("--force")) {
|
|
29
|
+
fail(
|
|
30
|
+
`${PUBLIC_FILE} already exists. Rotating a key signs out every visitor holding a token ` +
|
|
31
|
+
`from the old one — re-run with --force if that is what you want.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
execFileSync("openssl", ["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", privatePath], {
|
|
37
|
+
stdio: "ignore"
|
|
38
|
+
});
|
|
39
|
+
execFileSync("openssl", ["rsa", "-in", privatePath, "-pubout", "-out", publicPath], { stdio: "ignore" });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (existsSync(privatePath)) unlinkSync(privatePath);
|
|
42
|
+
fail(`Could not run openssl: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The private half must never be pushed, and the surest way to keep it out is
|
|
46
|
+
// to keep it out of git — the deploy refuses it too, but by then it has
|
|
47
|
+
// already been read off disk.
|
|
48
|
+
ignorePrivateKey(root);
|
|
49
|
+
|
|
50
|
+
console.log(`Wrote ${PUBLIC_FILE} and ${PRIVATE_FILE}. They go to different places.`);
|
|
51
|
+
console.log("");
|
|
52
|
+
console.log(`1. ${PUBLIC_FILE} stays here. Commit it, and name it in vatio.yml:`);
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log(" auth:");
|
|
55
|
+
console.log(` public_key: ${PUBLIC_FILE}`);
|
|
56
|
+
console.log("");
|
|
57
|
+
console.log(`2. ${PRIVATE_FILE} goes into your backend as a secret — an env var, or`);
|
|
58
|
+
console.log(" whatever credential store you use. Your backend is what signs, so it");
|
|
59
|
+
console.log(" is the only thing that needs it. Then delete it from this directory:");
|
|
60
|
+
console.log("");
|
|
61
|
+
console.log(` VATIO_IDENTITY_PRIVATE_KEY="$(cat ${PRIVATE_FILE})"`);
|
|
62
|
+
console.log("");
|
|
63
|
+
console.log(" Not `vatio secrets set` — that store is read by Vatio, and the whole");
|
|
64
|
+
console.log(" point is that Vatio can check your tokens but never mint one.");
|
|
65
|
+
console.log(` (${PRIVATE_FILE} was added to .gitignore in the meantime.)`);
|
|
66
|
+
console.log("");
|
|
67
|
+
console.log("Sign a JWT with it for the person who is signed in:");
|
|
68
|
+
console.log("");
|
|
69
|
+
const claims = [
|
|
70
|
+
[ '"sub": "<your user id>",', "who this is" ],
|
|
71
|
+
[ `"aud": ${JSON.stringify(slug)},`, "this workspace, always" ],
|
|
72
|
+
[ '"exp": <unix seconds>,', "required; keep it short" ],
|
|
73
|
+
[ '"name": "Ada Lovelace",', "optional, fills the CRM" ],
|
|
74
|
+
[ '"email": "ada@example.com"', "optional, fills the CRM" ]
|
|
75
|
+
];
|
|
76
|
+
const width = Math.max(...claims.map(([ claim ]) => claim.length));
|
|
77
|
+
console.log(" {");
|
|
78
|
+
for (const [ claim, note ] of claims) {
|
|
79
|
+
console.log(` ${claim.padEnd(width)} // ${note}`);
|
|
80
|
+
}
|
|
81
|
+
console.log(" }");
|
|
82
|
+
console.log("");
|
|
83
|
+
console.log("Put it on the widget as data-visitor-token, and push.");
|
|
84
|
+
console.log("Full examples: https://vatio.ai/docs#auth");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ignorePrivateKey(root) {
|
|
88
|
+
const path = join(root, ".gitignore");
|
|
89
|
+
const line = PRIVATE_FILE;
|
|
90
|
+
let body = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
91
|
+
if (body.split(/\r?\n/).some((entry) => entry.trim() === line)) return;
|
|
92
|
+
|
|
93
|
+
if (body.length > 0 && !body.endsWith("\n")) body += "\n";
|
|
94
|
+
writeFileSync(path, `${body}${line}\n`);
|
|
95
|
+
}
|
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
// vatio.yml is a list of *names*, and a push neither fills nor empties one.
|
|
5
5
|
// That is why this is a command and not part of the manifest, and why live and
|
|
6
6
|
// preview read the same base.
|
|
7
|
+
//
|
|
8
|
+
// A base holds entries, and an entry is markdown. Either you wrote it, or a
|
|
9
|
+
// site wrote it from a page it read — and a site is one URL or one pattern,
|
|
10
|
+
// re-read every night. Everything below is one of those two nouns.
|
|
7
11
|
|
|
8
|
-
import { basename } from "node:path";
|
|
9
12
|
import { readFileSync, statSync } from "node:fs";
|
|
10
13
|
|
|
11
14
|
import { deployClient } from "./deploy.mjs";
|
|
12
|
-
import { fail
|
|
15
|
+
import { fail } from "../support.mjs";
|
|
13
16
|
|
|
14
17
|
export async function kb(config, args) {
|
|
15
18
|
const workspace = config.resolveWorkspaceRequired();
|
|
@@ -19,12 +22,14 @@ export async function kb(config, args) {
|
|
|
19
22
|
switch (sub) {
|
|
20
23
|
case "list": return await list(client, workspace);
|
|
21
24
|
case "show": return await show(client, workspace, args.shift());
|
|
22
|
-
case "create": return await create(client, workspace, args.
|
|
25
|
+
case "create": return await create(client, workspace, args.splice(0));
|
|
23
26
|
case "rm": return await remove(client, workspace, args.shift());
|
|
24
|
-
case "
|
|
25
|
-
case "
|
|
26
|
-
case "rm-
|
|
27
|
-
case "
|
|
27
|
+
case "write": return await write(client, workspace, args);
|
|
28
|
+
case "cat": return await cat(client, args.shift(), args.shift());
|
|
29
|
+
case "rm-entry": return await removeEntry(client, workspace, args.shift(), args.shift());
|
|
30
|
+
case "follow": return await follow(client, workspace, args.shift(), args.shift());
|
|
31
|
+
case "unfollow": return await unfollow(client, workspace, args.shift(), args.shift());
|
|
32
|
+
case "refresh": return await refresh(client, workspace, args.shift(), args.shift());
|
|
28
33
|
default: fail(`Unknown kb subcommand: ${sub}\n\n${usage()}`);
|
|
29
34
|
}
|
|
30
35
|
}
|
|
@@ -32,13 +37,15 @@ export async function kb(config, args) {
|
|
|
32
37
|
function usage() {
|
|
33
38
|
return `usage:
|
|
34
39
|
vatio kb List knowledge bases
|
|
35
|
-
vatio kb show NAME One base and its
|
|
36
|
-
vatio kb create NAME
|
|
40
|
+
vatio kb show NAME One base: its sites and its entries
|
|
41
|
+
vatio kb create NAME [NAME...] Create one base, or several
|
|
37
42
|
vatio kb rm NAME Delete a base (must be unreferenced)
|
|
38
|
-
vatio kb
|
|
39
|
-
vatio kb
|
|
40
|
-
vatio kb rm-
|
|
41
|
-
vatio kb
|
|
43
|
+
vatio kb write BASE ENTRY [FILE] Write an entry, from FILE or stdin
|
|
44
|
+
vatio kb cat BASE ENTRY Print an entry's markdown
|
|
45
|
+
vatio kb rm-entry BASE ENTRY Delete one entry
|
|
46
|
+
vatio kb follow BASE URL Read a site into the base, nightly
|
|
47
|
+
vatio kb unfollow BASE URL Stop reading it, and drop its entries
|
|
48
|
+
vatio kb refresh BASE [URL] Read the sites again now`;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
async function list(client, workspace) {
|
|
@@ -51,7 +58,10 @@ async function list(client, workspace) {
|
|
|
51
58
|
|
|
52
59
|
console.log(`Knowledge bases on workspace ${workspace} (${bases.length}):`);
|
|
53
60
|
for (const row of bases) {
|
|
54
|
-
console.log(
|
|
61
|
+
console.log(
|
|
62
|
+
` ${row.name} entries=${row.entries_count} ready=${row.answerable_count} ` +
|
|
63
|
+
`sites=${row.sites_count}${referenceNote(row)}`
|
|
64
|
+
);
|
|
55
65
|
}
|
|
56
66
|
}
|
|
57
67
|
|
|
@@ -66,34 +76,64 @@ async function show(client, workspace, name) {
|
|
|
66
76
|
if (!name) fail("usage: vatio kb show NAME");
|
|
67
77
|
|
|
68
78
|
const base = await client.knowledgeBase(name);
|
|
69
|
-
console.log(
|
|
79
|
+
console.log(
|
|
80
|
+
`${base.name} on workspace ${workspace}: entries=${base.entries_count} ` +
|
|
81
|
+
`ready=${base.answerable_count}${referenceNote(base)}`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const sites = asArray(base.sites);
|
|
85
|
+
if (sites.length > 0) {
|
|
86
|
+
console.log("\n sites:");
|
|
87
|
+
for (const site of sites) {
|
|
88
|
+
const when = site.last_run_at ?? "never";
|
|
89
|
+
console.log(
|
|
90
|
+
` ${site.url} ${site.status} pages=${site.pages_found_count} ` +
|
|
91
|
+
`entries=${site.entries_written_count} last_read=${when}`
|
|
92
|
+
);
|
|
93
|
+
if (site.last_error) console.log(` error: ${site.last_error}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
70
96
|
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
97
|
+
const entries = asArray(base.entries);
|
|
98
|
+
if (entries.length === 0) {
|
|
73
99
|
console.log(
|
|
74
|
-
|
|
100
|
+
`\n no entries yet — write one with \`vatio kb write ${name} ENTRY FILE\`, ` +
|
|
101
|
+
`or read a site with \`vatio kb follow ${name} acme.com/help/**\``
|
|
75
102
|
);
|
|
76
103
|
return;
|
|
77
104
|
}
|
|
78
105
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`entries=${row.entries_count} last_indexed_at=${lastCrawled}`
|
|
84
|
-
);
|
|
85
|
-
console.log(row.kind === "upload" ? ` ${row.filename}` : ` ${row.site_url} ${patternsDisplay(row)}`);
|
|
86
|
-
if (row.last_error) console.log(` error: ${row.last_error}`);
|
|
106
|
+
console.log("\n entries:");
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const origin = entry.written ? "written here" : entry.url;
|
|
109
|
+
console.log(` ${entry.name} ${entry.state ?? "?"} ${origin}`);
|
|
87
110
|
}
|
|
88
111
|
}
|
|
89
112
|
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
// Several names, because the moment this command is most often run is a push
|
|
114
|
+
// that just refused for naming bases that do not exist yet -- and that push
|
|
115
|
+
// names all of them at once. One base per invocation turned a first deploy
|
|
116
|
+
// into one round trip per name.
|
|
117
|
+
//
|
|
118
|
+
// Still never implicit: a base is created because someone typed its name here,
|
|
119
|
+
// which is what keeps `knowledge: [defualt]` a failed push instead of an empty
|
|
120
|
+
// base the agent searches forever.
|
|
121
|
+
async function create(client, workspace, names) {
|
|
122
|
+
const wanted = names.filter((name) => name.trim() !== "");
|
|
123
|
+
if (wanted.length === 0) fail("usage: vatio kb create NAME [NAME...]");
|
|
124
|
+
|
|
125
|
+
const created = [];
|
|
126
|
+
for (const name of wanted) {
|
|
127
|
+
const base = await client.createKnowledgeBase(name);
|
|
128
|
+
created.push(base.name);
|
|
129
|
+
console.log(`Created knowledge base ${base.name} on workspace ${workspace}`);
|
|
130
|
+
}
|
|
92
131
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
console.log(`
|
|
132
|
+
console.log(created.length === 1
|
|
133
|
+
? "It is empty and nothing reads it yet."
|
|
134
|
+
: "They are empty and nothing reads them yet.");
|
|
135
|
+
console.log("Fill with `vatio kb write` or `vatio kb follow`, then add");
|
|
136
|
+
console.log(`\`knowledge: [${created.join(", ")}]\` to the agent in vatio.yml and push.`);
|
|
97
137
|
}
|
|
98
138
|
|
|
99
139
|
async function remove(client, workspace, name) {
|
|
@@ -103,143 +143,94 @@ async function remove(client, workspace, name) {
|
|
|
103
143
|
console.log(`Deleted knowledge base ${name} on workspace ${workspace}`);
|
|
104
144
|
}
|
|
105
145
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
// Put this markdown under this name. One shape, positional, no flags: the
|
|
147
|
+
// entry's name is the only thing the command cannot work out for itself — the
|
|
148
|
+
// title comes from the document's own `# heading`, and the body comes from the
|
|
149
|
+
// file or from stdin.
|
|
150
|
+
//
|
|
151
|
+
// Creates the entry if the name is free and replaces it if it is taken, so the
|
|
152
|
+
// round trip is the obvious one:
|
|
153
|
+
//
|
|
154
|
+
// vatio kb cat docs horarios > horarios.md
|
|
155
|
+
// $EDITOR horarios.md
|
|
156
|
+
// vatio kb write docs horarios horarios.md
|
|
157
|
+
async function write(client, workspace, args) {
|
|
158
|
+
const [base, name, file] = args;
|
|
159
|
+
if (!base || !name) fail("usage: vatio kb write BASE ENTRY [FILE] (or pipe the markdown in)");
|
|
160
|
+
if (file && !isFile(file)) fail(`No such file: ${file}`);
|
|
161
|
+
|
|
162
|
+
const content = file ? readFileSync(file, "utf8") : readStdin();
|
|
163
|
+
if (!content.trim()) {
|
|
164
|
+
fail("Nothing to write: pass a FILE, or pipe the markdown in on stdin.");
|
|
113
165
|
}
|
|
114
166
|
|
|
115
|
-
const row = await client.
|
|
116
|
-
console.log(`
|
|
117
|
-
console.log(`Crawling runs in the background — check progress with \`vatio kb show ${base}\``);
|
|
167
|
+
const row = await client.writeKnowledgeEntry({ base, name, content });
|
|
168
|
+
console.log(`Wrote ${row.name} in ${base} on workspace ${workspace}`);
|
|
118
169
|
}
|
|
119
170
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!base || paths.length === 0) fail("usage: vatio kb upload BASE FILE...");
|
|
171
|
+
// Prints exactly what the platform holds, so a diff against the file you
|
|
172
|
+
// pushed is a real answer to "is what I sent what it has".
|
|
173
|
+
async function cat(client, base, name) {
|
|
174
|
+
if (!base || !name) fail("usage: vatio kb cat BASE ENTRY");
|
|
125
175
|
|
|
126
|
-
const
|
|
127
|
-
|
|
176
|
+
const row = await client.knowledgeEntry({ base, name });
|
|
177
|
+
const content = row.content ?? "";
|
|
178
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
179
|
+
}
|
|
128
180
|
|
|
129
|
-
|
|
181
|
+
async function removeEntry(client, workspace, base, name) {
|
|
182
|
+
if (!base || !name) fail("usage: vatio kb rm-entry BASE ENTRY");
|
|
130
183
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
base,
|
|
134
|
-
filename: basename(path),
|
|
135
|
-
content: readFileSync(path, "utf8")
|
|
136
|
-
});
|
|
137
|
-
console.log(`Uploaded ${basename(path)} to ${base} as ${row.name} (entries=${row.entries_count})`);
|
|
138
|
-
}
|
|
139
|
-
console.log(`Indexing runs in the background — check progress with \`vatio kb show ${base}\``);
|
|
184
|
+
await client.deleteKnowledgeEntry({ base, name });
|
|
185
|
+
console.log(`Deleted entry ${name} from ${base} on workspace ${workspace}`);
|
|
140
186
|
}
|
|
141
187
|
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// on its own, but one file at a time means a collision on file twenty lands
|
|
145
|
-
// after nineteen are already in -- half an upload nobody asked for.
|
|
188
|
+
// One URL, and the shape of it is the whole configuration. A URL is that URL;
|
|
189
|
+
// a pattern is every page that matches it:
|
|
146
190
|
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
async function
|
|
152
|
-
|
|
153
|
-
const crawls = new Map(sources.filter((row) => row.kind === "crawl").map((row) => [row.name, row]));
|
|
154
|
-
if (crawls.size === 0) return;
|
|
155
|
-
|
|
156
|
-
const collisions = paths
|
|
157
|
-
.map((path) => [path, crawls.get(sourceNameFor(path))])
|
|
158
|
-
.filter(([, crawl]) => crawl);
|
|
159
|
-
if (collisions.length === 0) return;
|
|
160
|
-
|
|
161
|
-
const lines = collisions.map(([path, crawl]) => {
|
|
162
|
-
const declaration = [crawl.site_url, ...asArray(crawl.include).map((p) => `include=${p}`)]
|
|
163
|
-
.filter(Boolean)
|
|
164
|
-
.join(" ");
|
|
165
|
-
return ` ${basename(path)} would land on "${crawl.name}", a crawl of ${declaration}`;
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
fail(
|
|
169
|
-
`Refusing to upload: ${collisions.length} of ${paths.length} file(s) collide with a crawl in ${base}.\n\n` +
|
|
170
|
-
`${lines.join("\n")}\n\n` +
|
|
171
|
-
"An upload cannot take over a crawl — its pages would keep answering lookups\n" +
|
|
172
|
-
"under a source that no longer says where they came from. Rename the file(s),\n" +
|
|
173
|
-
`or drop the crawl first with \`vatio kb rm-source ${base} NAME\`.\n\n` +
|
|
174
|
-
"Nothing was uploaded."
|
|
175
|
-
);
|
|
176
|
-
}
|
|
191
|
+
// acme.com the home page, and only it
|
|
192
|
+
// acme.com/help that page
|
|
193
|
+
// acme.com/** every page of the site
|
|
194
|
+
// acme.com/help/** that section, however deep
|
|
195
|
+
async function follow(client, workspace, base, url) {
|
|
196
|
+
if (!base || !url) fail("usage: vatio kb follow BASE URL");
|
|
177
197
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const stem = basename(String(path)).replace(/\.[^.]*$/, "").toLowerCase();
|
|
182
|
-
const slug = stem.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
183
|
-
return slug.slice(0, 60).replace(/-+$/, "");
|
|
198
|
+
const site = await client.followKnowledgeSite({ base, url });
|
|
199
|
+
console.log(`Reading ${site.url} into ${base} on workspace ${workspace}`);
|
|
200
|
+
console.log(`It runs in the background, and again every night — check it with \`vatio kb show ${base}\``);
|
|
184
201
|
}
|
|
185
202
|
|
|
186
|
-
async function
|
|
187
|
-
if (!base || !
|
|
203
|
+
async function unfollow(client, workspace, base, url) {
|
|
204
|
+
if (!base || !url) fail("usage: vatio kb unfollow BASE URL");
|
|
188
205
|
|
|
189
|
-
await client.
|
|
190
|
-
console.log(`
|
|
206
|
+
await client.unfollowKnowledgeSite({ base, url });
|
|
207
|
+
console.log(`Stopped reading ${url} into ${base} on workspace ${workspace}`);
|
|
208
|
+
console.log("Its entries went with it: nothing was left that nothing would refresh.");
|
|
191
209
|
}
|
|
192
210
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// re-read, so "reindex everything" cannot mean them.
|
|
196
|
-
async function reindex(client, workspace, base, name) {
|
|
197
|
-
if (!base) fail("usage: vatio kb reindex BASE [NAME]");
|
|
211
|
+
async function refresh(client, workspace, base, url) {
|
|
212
|
+
if (!base) fail("usage: vatio kb refresh BASE [URL]");
|
|
198
213
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
214
|
+
if (url) {
|
|
215
|
+
const site = await client.refreshKnowledgeSite({ base, url });
|
|
216
|
+
console.log(`Reading ${site.url} again in ${base} on workspace ${workspace}`);
|
|
202
217
|
} else {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
targets = crawls.map((row) => row.name);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
for (const target of targets) {
|
|
209
|
-
const row = await client.reindexKnowledgeSource({ base, name: target });
|
|
210
|
-
console.log(`Reindexing ${row.name} in ${base} on workspace ${workspace} (status=${row.status})`);
|
|
218
|
+
const row = await client.refreshKnowledgeBase(base);
|
|
219
|
+
console.log(`Reading every site in ${base} again on workspace ${workspace} (${asArray(row.sites).length} of them)`);
|
|
211
220
|
}
|
|
212
|
-
console.log(`
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// pages_discovered_count is newer than pages_indexed_count -- a platform
|
|
216
|
-
// predating it omits the key entirely, so this falls back to the old
|
|
217
|
-
// single-number display rather than printing "pages=3/".
|
|
218
|
-
function pagesDisplay(row) {
|
|
219
|
-
const discovered = row.pages_discovered_count;
|
|
220
|
-
const indexed = row.pages_indexed_count;
|
|
221
|
-
return discovered ? `${indexed}/${discovered}` : String(indexed ?? "");
|
|
221
|
+
console.log(`Runs in the background — check progress with \`vatio kb show ${base}\``);
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (includes.length === 0 && excludes.length === 0) return "whole site";
|
|
230
|
-
|
|
231
|
-
const parts = [];
|
|
232
|
-
if (includes.length > 0) parts.push(`include=${includes.join(",")}`);
|
|
233
|
-
if (excludes.length > 0) parts.push(`exclude=${excludes.join(",")}`);
|
|
234
|
-
return parts.join(" ");
|
|
235
|
-
}
|
|
224
|
+
// Blocking on purpose: `vatio kb write docs refunds < refunds.md` should behave
|
|
225
|
+
// like every other filter, and a terminal with nobody piping into it returns ""
|
|
226
|
+
// rather than hanging on a read that will never end.
|
|
227
|
+
function readStdin() {
|
|
228
|
+
if (process.stdin.isTTY) return "";
|
|
236
229
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (value === null) return values;
|
|
242
|
-
values.push(value);
|
|
230
|
+
try {
|
|
231
|
+
return readFileSync(0, "utf8");
|
|
232
|
+
} catch {
|
|
233
|
+
return "";
|
|
243
234
|
}
|
|
244
235
|
}
|
|
245
236
|
|
|
@@ -96,18 +96,42 @@ async function issueTemplate(config) {
|
|
|
96
96
|
process.stdout.write(await response.text());
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// Where a report stands, in the words the developer is actually asking in:
|
|
100
|
+
// has anyone looked at it, and is the ball with them or with us. There is no
|
|
101
|
+
// `status` field to print -- an issue has no states -- so this reads the few
|
|
102
|
+
// facts the platform does publish about the thread.
|
|
103
|
+
function issueStatus(row) {
|
|
104
|
+
if (row.resolved) return "closed";
|
|
105
|
+
// The developer wrote last and nothing has gone back yet.
|
|
106
|
+
if (row.awaiting_us) return "with us";
|
|
107
|
+
if (row.answered) return "answered";
|
|
108
|
+
if (row.answers > 0) return "in progress";
|
|
109
|
+
return "sent";
|
|
110
|
+
}
|
|
111
|
+
|
|
99
112
|
async function issueList(config) {
|
|
113
|
+
// `data`, the same envelope every other read in this CLI unwraps. Reading
|
|
114
|
+
// `issues` here is how `list` spent its whole life reporting that a
|
|
115
|
+
// developer had sent nothing while `show` returned those same issues by id.
|
|
100
116
|
const payload = await issuesClient(config).get("/cli/issues");
|
|
101
|
-
const issues = Array.isArray(payload.
|
|
117
|
+
const issues = Array.isArray(payload.data) ? payload.data : [];
|
|
102
118
|
if (issues.length === 0) {
|
|
103
119
|
console.log("You have not sent any issues yet.");
|
|
104
120
|
console.log(' Send one: vatio issue "what should change"');
|
|
105
121
|
return;
|
|
106
122
|
}
|
|
107
123
|
console.log(`Your issues (${issues.length}):`);
|
|
124
|
+
const idWidth = Math.max(...issues.map((row) => String(row.id).length));
|
|
125
|
+
const statusWidth = Math.max(...issues.map((row) => issueStatus(row).length));
|
|
126
|
+
// The pull request goes under the title it belongs to, so the id and status
|
|
127
|
+
// columns stay readable as columns.
|
|
128
|
+
const indent = " ".repeat(2 + idWidth + 3 + statusWidth + 2);
|
|
108
129
|
for (const row of issues) {
|
|
109
|
-
|
|
130
|
+
const id = `[${String(row.id).padStart(idWidth)}]`;
|
|
131
|
+
console.log(` ${id} ${issueStatus(row).padEnd(statusWidth)} ${row.title ?? ""}`.trimEnd());
|
|
132
|
+
if (row.pull_request_url) console.log(`${indent}${row.pull_request_url}`);
|
|
110
133
|
}
|
|
134
|
+
console.log("Read one, conversation included: vatio issue show ID");
|
|
111
135
|
}
|
|
112
136
|
|
|
113
137
|
// The markdown view, which is the same document Vatio's own triage agent
|
|
@@ -108,10 +108,6 @@ export async function tokens(config, args) {
|
|
|
108
108
|
fail("Usage: vatio tokens list|create [--env live|preview|NAME] [--label NAME]|revoke PREFIX");
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
// Everything needed to put the bubble on a page, in one screen: what the
|
|
112
|
-
// platform will actually enforce, the tokens that exist, and the snippet to
|
|
113
|
-
// paste. Read-only -- vatio.yml owns every field, so `vatio push` is what
|
|
114
|
-
// changes it.
|
|
115
111
|
export async function widget(config, args) {
|
|
116
112
|
const environment = environmentOption(args, "live");
|
|
117
113
|
const workspace = config.resolveWorkspaceRequired();
|
package/lib/help.mjs
CHANGED
|
@@ -39,16 +39,19 @@ flag, the directory decides.
|
|
|
39
39
|
|
|
40
40
|
Knowledge:
|
|
41
41
|
kb [list] Bases, and whether a deployed agent reads each
|
|
42
|
-
kb show NAME One base and its
|
|
42
|
+
kb show NAME One base: its sites and its entries
|
|
43
43
|
kb create NAME | rm NAME
|
|
44
|
-
kb
|
|
45
|
-
kb
|
|
46
|
-
kb rm-
|
|
44
|
+
kb write BASE ENTRY [FILE] Write an entry, from FILE or stdin
|
|
45
|
+
kb cat BASE ENTRY Print an entry's markdown
|
|
46
|
+
kb rm-entry BASE ENTRY Delete one entry
|
|
47
|
+
kb follow BASE URL Read a site into the base, nightly
|
|
48
|
+
kb unfollow BASE URL | refresh BASE [URL]
|
|
47
49
|
|
|
48
50
|
Credentials and the widget:
|
|
49
51
|
secrets list|set KEY VALUE|rm KEY
|
|
50
52
|
tokens list|create [--env NAME] [--label NAME]|revoke PREFIX
|
|
51
53
|
widget [--env NAME] What the platform enforces, and the tokens there are
|
|
54
|
+
auth --new-key Keypair that signs the JWT saying who a visitor is
|
|
52
55
|
|
|
53
56
|
Channels (your own account serves live; the shared preview always serves preview):
|
|
54
57
|
whatsapp [status]|connect|check|activate|deactivate|disconnect
|
package/lib/workspace.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// contract -- the port of cli/lib/workspace_root.rb, and deliberately just as
|
|
3
3
|
// small.
|
|
4
4
|
//
|
|
5
|
-
// What a `tools/*.
|
|
5
|
+
// What a `tools/*.yml` declares, which blocks `vatio.yml` accepts and whether
|
|
6
6
|
// any of it is valid are answered by POST /api/v1/:slug/deploy/check. The one
|
|
7
7
|
// thing a client cannot ask the platform for is the address of that call: the
|
|
8
8
|
// slug is in its URL. So this reads `workspace:` and stops.
|
|
@@ -16,7 +16,8 @@ export const MANIFEST_FILE = "vatio.yml";
|
|
|
16
16
|
// past this is still the platform's to reject; matching here only turns a typo
|
|
17
17
|
// into a sentence instead of a 404.
|
|
18
18
|
export const SLUG_FORMAT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
19
|
-
//
|
|
19
|
+
// The key `agent:` stores its single agent under, and the default an `entry:`
|
|
20
|
+
// table falls back to. A workspace may declare several under `agents:`.
|
|
20
21
|
export const ENTRY_AGENT = "main";
|
|
21
22
|
|
|
22
23
|
// The manifest in a directory, or null when the directory is not a workspace
|