@vatio-ai/cli 0.37.2 → 0.43.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 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":
@@ -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
- addKnowledgeCrawl({ base, name, siteUrl, includes = [], excludes = [] }) {
94
- return this.post(`/knowledge_bases/${encode(base)}/sources`, {
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
- // The file's bytes travel in the body: the platform chunks and embeds, and
103
- // it is the only copy there will be.
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
- deleteKnowledgeSource({ base, name }) {
109
- return this.delete(`/knowledge_bases/${encode(base)}/sources/${encode(name)}`);
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
- reindexKnowledgeSource({ base, name }) {
113
- return this.post(`/knowledge_bases/${encode(base)}/sources/${encode(name)}/reindex`, {});
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
- showChat({ chatId, view = "visitor" }) {
244
- return this.get(`/chats/${encode(chatId)}`, { view });
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, view = "visitor", after = null, limit = null }) {
248
- const params = { view };
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);
@@ -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, view: "visitor", after });
165
+ const payload = await api.chatMessages({ chatId, after });
156
166
  const messages = Array.isArray(payload.data) ? payload.data : [];
157
- const assistant = [...messages].reverse().find(
158
- (entry) => entry.role === "assistant" && String(entry.content ?? "").trim() !== ""
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((entry) => ["user", "assistant"].includes(entry.role) && !entry.discarded)
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, view: "developer" }), null, 2));
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, view: "developer", limit: options.last });
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, takeValue } from "../support.mjs";
15
+ import { fail } from "../support.mjs";
13
16
 
14
17
  export async function kb(config, args) {
15
18
  const workspace = config.resolveWorkspaceRequired();
@@ -21,10 +24,12 @@ export async function kb(config, args) {
21
24
  case "show": return await show(client, workspace, args.shift());
22
25
  case "create": return await create(client, workspace, args.shift());
23
26
  case "rm": return await remove(client, workspace, args.shift());
24
- case "add-source": return await addSource(client, workspace, args);
25
- case "upload": return await upload(client, workspace, args.shift(), args);
26
- case "rm-source": return await removeSource(client, workspace, args.shift(), args.shift());
27
- case "reindex": return await reindex(client, workspace, args.shift(), args.shift());
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 sources
40
+ vatio kb show NAME One base: its sites and its entries
36
41
  vatio kb create NAME Create a base
37
42
  vatio kb rm NAME Delete a base (must be unreferenced)
38
- vatio kb add-source BASE NAME URL Add a crawl
39
- vatio kb upload BASE FILE... Upload files into a base
40
- vatio kb rm-source BASE NAME Delete one source
41
- vatio kb reindex BASE [NAME] Re-crawl one source, or every crawl`;
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(` ${row.name} sources=${row.sources_count} entries=${row.entries_count}${referenceNote(row)}`);
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,24 +76,37 @@ 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(`${base.name} on workspace ${workspace}: entries=${base.entries_count}${referenceNote(base)}`);
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 sources = asArray(base.sources);
72
- if (sources.length === 0) {
97
+ const entries = asArray(base.entries);
98
+ if (entries.length === 0) {
73
99
  console.log(
74
- ` no sources yet — add one with \`vatio kb add-source ${name} NAME URL\` or \`vatio kb upload ${name} FILE\``
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
- for (const row of sources) {
80
- const lastCrawled = row.last_crawled_at ?? "never";
81
- console.log(
82
- ` ${row.name} [${row.kind}] ${row.status} pages=${pagesDisplay(row)} ` +
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
 
@@ -92,7 +115,7 @@ async function create(client, workspace, name) {
92
115
 
93
116
  const base = await client.createKnowledgeBase(name);
94
117
  console.log(`Created knowledge base ${base.name} on workspace ${workspace}`);
95
- console.log("It is empty and nothing reads it yet. Fill it with `vatio kb add-source` or `vatio kb upload`,");
118
+ console.log("It is empty and nothing reads it yet. Fill it with `vatio kb write` or `vatio kb follow`,");
96
119
  console.log(`then add \`knowledge: [${base.name}]\` to vatio.yml and push.`);
97
120
  }
98
121
 
@@ -103,143 +126,94 @@ async function remove(client, workspace, name) {
103
126
  console.log(`Deleted knowledge base ${name} on workspace ${workspace}`);
104
127
  }
105
128
 
106
- async function addSource(client, workspace, args) {
107
- const includes = takeAll(args, "--include");
108
- const excludes = takeAll(args, "--exclude");
109
- const [base, name, siteUrl] = args;
110
-
111
- if (!base || !name || !siteUrl) {
112
- fail("usage: vatio kb add-source BASE NAME URL [--include PATH] [--exclude PATH]");
129
+ // Put this markdown under this name. One shape, positional, no flags: the
130
+ // entry's name is the only thing the command cannot work out for itself — the
131
+ // title comes from the document's own `# heading`, and the body comes from the
132
+ // file or from stdin.
133
+ //
134
+ // Creates the entry if the name is free and replaces it if it is taken, so the
135
+ // round trip is the obvious one:
136
+ //
137
+ // vatio kb cat docs horarios > horarios.md
138
+ // $EDITOR horarios.md
139
+ // vatio kb write docs horarios horarios.md
140
+ async function write(client, workspace, args) {
141
+ const [base, name, file] = args;
142
+ if (!base || !name) fail("usage: vatio kb write BASE ENTRY [FILE] (or pipe the markdown in)");
143
+ if (file && !isFile(file)) fail(`No such file: ${file}`);
144
+
145
+ const content = file ? readFileSync(file, "utf8") : readStdin();
146
+ if (!content.trim()) {
147
+ fail("Nothing to write: pass a FILE, or pipe the markdown in on stdin.");
113
148
  }
114
149
 
115
- const row = await client.addKnowledgeCrawl({ base, name, siteUrl, includes, excludes });
116
- console.log(`Added crawl ${row.name} to ${base} on workspace ${workspace} (status=${row.status})`);
117
- console.log(`Crawling runs in the background — check progress with \`vatio kb show ${base}\``);
150
+ const row = await client.writeKnowledgeEntry({ base, name, content });
151
+ console.log(`Wrote ${row.name} in ${base} on workspace ${workspace}`);
118
152
  }
119
153
 
120
- // Every file in one command, because `knowledge/*.md` is how a repo that
121
- // predates knowledge bases holds its content, and moving it should be one line
122
- // rather than one line per file.
123
- async function upload(client, workspace, base, paths) {
124
- if (!base || paths.length === 0) fail("usage: vatio kb upload BASE FILE...");
154
+ // Prints exactly what the platform holds, so a diff against the file you
155
+ // pushed is a real answer to "is what I sent what it has".
156
+ async function cat(client, base, name) {
157
+ if (!base || !name) fail("usage: vatio kb cat BASE ENTRY");
125
158
 
126
- const missing = paths.filter((path) => !isFile(path));
127
- if (missing.length > 0) fail(`No such file: ${missing.join(", ")}`);
159
+ const row = await client.knowledgeEntry({ base, name });
160
+ const content = row.content ?? "";
161
+ process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
162
+ }
128
163
 
129
- await abortOnCrawlCollisions(client, base, paths);
164
+ async function removeEntry(client, workspace, base, name) {
165
+ if (!base || !name) fail("usage: vatio kb rm-entry BASE ENTRY");
130
166
 
131
- for (const path of paths) {
132
- const row = await client.uploadKnowledgeDocument({
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}\``);
167
+ await client.deleteKnowledgeEntry({ base, name });
168
+ console.log(`Deleted entry ${name} from ${base} on workspace ${workspace}`);
140
169
  }
141
170
 
142
- // Checked for the whole batch before a single file goes up, so an upload of
143
- // forty files either happens or does not. The platform refuses each collision
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.
171
+ // One URL, and the shape of it is the whole configuration. A URL is that URL;
172
+ // a pattern is every page that matches it:
146
173
  //
147
- // Advisory, not the guarantee: the platform is what actually refuses. If this
148
- // check and its naming rule ever drift, the worst case is that this misses a
149
- // collision and the upload stops there instead, which is where it stopped
150
- // before.
151
- async function abortOnCrawlCollisions(client, base, paths) {
152
- const sources = asArray((await client.knowledgeBase(base)).sources);
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
- }
174
+ // acme.com the home page, and only it
175
+ // acme.com/help that page
176
+ // acme.com/** every page of the site
177
+ // acme.com/help/** that section, however deep
178
+ async function follow(client, workspace, base, url) {
179
+ if (!base || !url) fail("usage: vatio kb follow BASE URL");
177
180
 
178
- // Mirrors the platform's own source naming. Kept in step by the platform
179
- // refusing anyway, not by this being right.
180
- function sourceNameFor(path) {
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(/-+$/, "");
181
+ const site = await client.followKnowledgeSite({ base, url });
182
+ console.log(`Reading ${site.url} into ${base} on workspace ${workspace}`);
183
+ console.log(`It runs in the background, and again every night — check it with \`vatio kb show ${base}\``);
184
184
  }
185
185
 
186
- async function removeSource(client, workspace, base, name) {
187
- if (!base || !name) fail("usage: vatio kb rm-source BASE NAME");
186
+ async function unfollow(client, workspace, base, url) {
187
+ if (!base || !url) fail("usage: vatio kb unfollow BASE URL");
188
188
 
189
- await client.deleteKnowledgeSource({ base, name });
190
- console.log(`Deleted source ${name} from ${base} on workspace ${workspace}`);
189
+ await client.unfollowKnowledgeSite({ base, url });
190
+ console.log(`Stopped reading ${url} into ${base} on workspace ${workspace}`);
191
+ console.log("Its entries went with it: nothing was left that nothing would refresh.");
191
192
  }
192
193
 
193
- // A bare `vatio kb reindex BASE` re-runs every crawl in the base. Uploads are
194
- // skipped rather than failing the whole command: there is no upstream to
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]");
194
+ async function refresh(client, workspace, base, url) {
195
+ if (!base) fail("usage: vatio kb refresh BASE [URL]");
198
196
 
199
- let targets;
200
- if (name) {
201
- targets = [name];
197
+ if (url) {
198
+ const site = await client.refreshKnowledgeSite({ base, url });
199
+ console.log(`Reading ${site.url} again in ${base} on workspace ${workspace}`);
202
200
  } else {
203
- const crawls = asArray((await client.knowledgeBase(base)).sources).filter((row) => row.kind === "crawl");
204
- if (crawls.length === 0) fail(`No crawls to reindex in ${base} on workspace ${workspace}`);
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})`);
201
+ const row = await client.refreshKnowledgeBase(base);
202
+ console.log(`Reading every site in ${base} again on workspace ${workspace} (${asArray(row.sites).length} of them)`);
211
203
  }
212
- console.log(`Crawling runs in the background — check progress with \`vatio kb show ${base}\``);
204
+ console.log(`Runs in the background — check progress with \`vatio kb show ${base}\``);
213
205
  }
214
206
 
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 ?? "");
222
- }
223
-
224
- // No patterns at all means the source covers the whole site, which is the
225
- // default and needs saying rather than printing blank.
226
- function patternsDisplay(row) {
227
- const includes = asArray(row.include);
228
- const excludes = asArray(row.exclude);
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
- }
207
+ // Blocking on purpose: `vatio kb write docs refunds < refunds.md` should behave
208
+ // like every other filter, and a terminal with nobody piping into it returns ""
209
+ // rather than hanging on a read that will never end.
210
+ function readStdin() {
211
+ if (process.stdin.isTTY) return "";
236
212
 
237
- function takeAll(args, name) {
238
- const values = [];
239
- for (;;) {
240
- const value = takeValue(args, name);
241
- if (value === null) return values;
242
- values.push(value);
213
+ try {
214
+ return readFileSync(0, "utf8");
215
+ } catch {
216
+ return "";
243
217
  }
244
218
  }
245
219
 
@@ -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 sources
42
+ kb show NAME One base: its sites and its entries
43
43
  kb create NAME | rm NAME
44
- kb add-source BASE NAME URL [--include P] [--exclude P]
45
- kb upload BASE FILE... Upload files into a base
46
- kb rm-source BASE NAME | reindex BASE [NAME]
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/*.js` declares, which blocks `vatio.yml` accepts and whether
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
- // A workspace has exactly one agent and it is the conversation entry point.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vatio-ai/cli",
3
- "version": "0.37.2",
3
+ "version": "0.43.0",
4
4
  "description": "Vatio CLI — deploy and manage Vatio agent workspaces",
5
5
  "keywords": ["vatio", "agent", "ai", "cli", "deploy"],
6
6
  "homepage": "https://vatio.ai",