@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 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();
@@ -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.shift());
25
+ case "create": return await create(client, workspace, args.splice(0));
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
36
- vatio kb create NAME Create a base
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 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,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(`${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
 
90
- async function create(client, workspace, name) {
91
- if (!name) fail("usage: vatio kb create NAME");
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
- const base = await client.createKnowledgeBase(name);
94
- 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`,");
96
- console.log(`then add \`knowledge: [${base.name}]\` to vatio.yml and push.`);
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
- 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]");
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.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}\``);
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
- // 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...");
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 missing = paths.filter((path) => !isFile(path));
127
- if (missing.length > 0) fail(`No such file: ${missing.join(", ")}`);
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
- await abortOnCrawlCollisions(client, base, paths);
181
+ async function removeEntry(client, workspace, base, name) {
182
+ if (!base || !name) fail("usage: vatio kb rm-entry BASE ENTRY");
130
183
 
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}\``);
184
+ await client.deleteKnowledgeEntry({ base, name });
185
+ console.log(`Deleted entry ${name} from ${base} on workspace ${workspace}`);
140
186
  }
141
187
 
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.
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
- // 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
- }
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
- // 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(/-+$/, "");
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 removeSource(client, workspace, base, name) {
187
- if (!base || !name) fail("usage: vatio kb rm-source BASE NAME");
203
+ async function unfollow(client, workspace, base, url) {
204
+ if (!base || !url) fail("usage: vatio kb unfollow BASE URL");
188
205
 
189
- await client.deleteKnowledgeSource({ base, name });
190
- console.log(`Deleted source ${name} from ${base} on workspace ${workspace}`);
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
- // 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]");
211
+ async function refresh(client, workspace, base, url) {
212
+ if (!base) fail("usage: vatio kb refresh BASE [URL]");
198
213
 
199
- let targets;
200
- if (name) {
201
- targets = [name];
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 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})`);
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(`Crawling runs in the background — check progress with \`vatio kb show ${base}\``);
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
- // 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
- }
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
- 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);
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.issues) ? payload.issues : [];
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
- console.log(` [${row.id}] ${row.status ?? ""} ${row.title ?? row.summary ?? ""}`.trimEnd());
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 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.44.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",