@lotics/cli 0.150.2 → 0.152.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/dist/src/cli.js +312 -50
- package/dist/src/client.d.ts +38 -4
- package/dist/src/client.js +48 -6
- package/docs/cli_reference.md +6 -4
- package/docs/knowledge_docs.md +48 -4
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -44434,6 +44434,22 @@ var LoticsClient = class {
|
|
|
44434
44434
|
async getKnowledgeDoc(knowledge_doc_id) {
|
|
44435
44435
|
return this.request("GET", `/v1/knowledge_docs/${encodeURIComponent(knowledge_doc_id)}`);
|
|
44436
44436
|
}
|
|
44437
|
+
/**
|
|
44438
|
+
* Every doc's metadata — REST rather than the `list_knowledge` tool, because
|
|
44439
|
+
* this is the surface a PERSON manages the corpus from and the tool is bound
|
|
44440
|
+
* to what the assistant may browse. That distinction is the whole point of
|
|
44441
|
+
* `include_hidden`: a hidden doc is out of the assistant's corpus by design,
|
|
44442
|
+
* and someone still has to be able to find it in order to put it back.
|
|
44443
|
+
*/
|
|
44444
|
+
async listKnowledgeDocs(opts) {
|
|
44445
|
+
const qs = opts?.includeHidden ? "?include_hidden=true" : "";
|
|
44446
|
+
return this.request("GET", `/v1/knowledge_docs${qs}`);
|
|
44447
|
+
}
|
|
44448
|
+
/** Apply one metadata change to a set of docs. Tags are an add/remove DIFF —
|
|
44449
|
+
* see the endpoint's own note on why a replacement is the wrong shape here. */
|
|
44450
|
+
async bulkUpdateKnowledgeDocs(body) {
|
|
44451
|
+
return this.request("PATCH", "/v1/knowledge_docs", body);
|
|
44452
|
+
}
|
|
44437
44453
|
// --- Apps ---
|
|
44438
44454
|
async getApp(app_id) {
|
|
44439
44455
|
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}`);
|
|
@@ -45132,14 +45148,27 @@ var LoticsClient = class {
|
|
|
45132
45148
|
);
|
|
45133
45149
|
}
|
|
45134
45150
|
async uploadFiles(filePaths, options) {
|
|
45135
|
-
const
|
|
45151
|
+
const items = [];
|
|
45136
45152
|
for (let i2 = 0; i2 < filePaths.length; i2++) {
|
|
45137
45153
|
const absolutePath = path2.resolve(filePaths[i2]);
|
|
45138
|
-
|
|
45139
|
-
|
|
45140
|
-
|
|
45141
|
-
|
|
45142
|
-
|
|
45154
|
+
items.push({
|
|
45155
|
+
bytes: await fs2.promises.readFile(absolutePath),
|
|
45156
|
+
filename: options?.filenames?.[i2] ?? path2.basename(absolutePath)
|
|
45157
|
+
});
|
|
45158
|
+
}
|
|
45159
|
+
return this.uploadFileBytes(items);
|
|
45160
|
+
}
|
|
45161
|
+
/**
|
|
45162
|
+
* Store files from bytes the caller already holds — the path for a caller that
|
|
45163
|
+
* never had them on disk (an email attachment decoded in memory, a generated
|
|
45164
|
+
* document, a fetched URL). `uploadFiles` is this with a read in front, so
|
|
45165
|
+
* both routes hit one endpoint and one mime-derivation rule.
|
|
45166
|
+
*/
|
|
45167
|
+
async uploadFileBytes(items) {
|
|
45168
|
+
const formData = new FormData();
|
|
45169
|
+
for (const item of items) {
|
|
45170
|
+
const mimeType = item.mimeType ?? getMimeType(item.filename);
|
|
45171
|
+
formData.append("file", new Blob([item.bytes], { type: mimeType }), item.filename);
|
|
45143
45172
|
}
|
|
45144
45173
|
const url2 = `${this.baseUrl}/v1/files`;
|
|
45145
45174
|
const response = await fetch(url2, {
|
|
@@ -45152,6 +45181,92 @@ var LoticsClient = class {
|
|
|
45152
45181
|
}
|
|
45153
45182
|
};
|
|
45154
45183
|
|
|
45184
|
+
// src/upload_source.ts
|
|
45185
|
+
var MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
45186
|
+
var URL_FETCH_TIMEOUT_MS = 3e4;
|
|
45187
|
+
async function readStdinBytes(limit) {
|
|
45188
|
+
const chunks = [];
|
|
45189
|
+
let total = 0;
|
|
45190
|
+
for await (const chunk of process.stdin) {
|
|
45191
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
45192
|
+
total += buf.byteLength;
|
|
45193
|
+
if (total > limit) return { tooLarge: true };
|
|
45194
|
+
chunks.push(buf);
|
|
45195
|
+
}
|
|
45196
|
+
return Buffer.concat(chunks);
|
|
45197
|
+
}
|
|
45198
|
+
function decodeBase64Strict(input) {
|
|
45199
|
+
const text = Buffer.from(input).toString("utf8").trim();
|
|
45200
|
+
const canonical = text.replace(/\s+/g, "").replace(/=+$/, "");
|
|
45201
|
+
if (canonical.length > 0 && !/^[A-Za-z0-9+/]+$/.test(canonical)) return null;
|
|
45202
|
+
const decoded = Buffer.from(text, "base64");
|
|
45203
|
+
if (decoded.toString("base64").replace(/=+$/, "") !== canonical) return null;
|
|
45204
|
+
return decoded;
|
|
45205
|
+
}
|
|
45206
|
+
function filenameFromResponse(headers, url2) {
|
|
45207
|
+
const disposition = headers.get("content-disposition");
|
|
45208
|
+
const encoded = disposition?.match(/filename\*=\s*(?:UTF-8'')?"?([^";]+)"?/i);
|
|
45209
|
+
const plain = disposition?.match(/filename=\s*"?([^";]+)"?/i);
|
|
45210
|
+
const raw = encoded?.[1] ?? plain?.[1];
|
|
45211
|
+
if (raw) return decodeURIComponent(raw.trim());
|
|
45212
|
+
const last3 = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
45213
|
+
return last3 && last3.includes(".") ? decodeURIComponent(last3) : void 0;
|
|
45214
|
+
}
|
|
45215
|
+
async function resolveUploadSource(sourceFlag, flags) {
|
|
45216
|
+
let bytes;
|
|
45217
|
+
let filename = flags.as;
|
|
45218
|
+
if (flags.url !== void 0) {
|
|
45219
|
+
const controller = new AbortController();
|
|
45220
|
+
const timer2 = setTimeout(() => controller.abort(), URL_FETCH_TIMEOUT_MS);
|
|
45221
|
+
let response;
|
|
45222
|
+
try {
|
|
45223
|
+
response = await fetch(flags.url, { signal: controller.signal });
|
|
45224
|
+
} catch (e) {
|
|
45225
|
+
const reason = controller.signal.aborted ? `no response within ${URL_FETCH_TIMEOUT_MS}ms` : e instanceof Error ? e.message : String(e);
|
|
45226
|
+
return { error: `Fetch failed for ${flags.url}: ${reason}` };
|
|
45227
|
+
} finally {
|
|
45228
|
+
clearTimeout(timer2);
|
|
45229
|
+
}
|
|
45230
|
+
if (!response.ok) {
|
|
45231
|
+
return { error: `Fetch failed: ${response.status} ${response.statusText} for ${flags.url}` };
|
|
45232
|
+
}
|
|
45233
|
+
const declared = Number(response.headers.get("content-length"));
|
|
45234
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
|
|
45235
|
+
return { error: `That URL returns ${declared} bytes; the upload limit is ${MAX_UPLOAD_BYTES}.` };
|
|
45236
|
+
}
|
|
45237
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
45238
|
+
filename ??= filenameFromResponse(response.headers, flags.url);
|
|
45239
|
+
} else {
|
|
45240
|
+
const read = await readStdinBytes(MAX_UPLOAD_BYTES);
|
|
45241
|
+
if ("tooLarge" in read) {
|
|
45242
|
+
return { error: `stdin exceeded the ${MAX_UPLOAD_BYTES}-byte upload limit.` };
|
|
45243
|
+
}
|
|
45244
|
+
if (read.byteLength === 0) {
|
|
45245
|
+
return { error: `${sourceFlag} was given but stdin was empty \u2014 pipe the bytes in.` };
|
|
45246
|
+
}
|
|
45247
|
+
if (flags.base64) {
|
|
45248
|
+
const decoded = decodeBase64Strict(read);
|
|
45249
|
+
if (decoded === null) {
|
|
45250
|
+
return {
|
|
45251
|
+
error: "--base64 input is not valid base64: it contains characters outside the standard alphabet (base64url is not accepted) or is truncated."
|
|
45252
|
+
};
|
|
45253
|
+
}
|
|
45254
|
+
bytes = decoded;
|
|
45255
|
+
} else {
|
|
45256
|
+
bytes = read;
|
|
45257
|
+
}
|
|
45258
|
+
}
|
|
45259
|
+
if (bytes.byteLength > MAX_UPLOAD_BYTES) {
|
|
45260
|
+
return { error: `That is ${bytes.byteLength} bytes; the upload limit is ${MAX_UPLOAD_BYTES}.` };
|
|
45261
|
+
}
|
|
45262
|
+
if (filename === void 0) {
|
|
45263
|
+
return {
|
|
45264
|
+
error: "--as <name> is required: no filename could be derived from the source, and the mime type comes from it."
|
|
45265
|
+
};
|
|
45266
|
+
}
|
|
45267
|
+
return { bytes, filename };
|
|
45268
|
+
}
|
|
45269
|
+
|
|
45155
45270
|
// src/telemetry.ts
|
|
45156
45271
|
import crypto from "node:crypto";
|
|
45157
45272
|
import fs3 from "node:fs";
|
|
@@ -45894,15 +46009,24 @@ var COMMANDS = [
|
|
|
45894
46009
|
{
|
|
45895
46010
|
verbs: ["knowledge"],
|
|
45896
46011
|
help: [
|
|
45897
|
-
" lotics knowledge list
|
|
45898
|
-
"
|
|
46012
|
+
" lotics knowledge list [--include-hidden]",
|
|
46013
|
+
" List knowledge docs (id, name, tags, description)",
|
|
46014
|
+
" lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] --from <file.md>",
|
|
45899
46015
|
" Create a doc from a file (or --content <str>);",
|
|
45900
46016
|
" prints the new id",
|
|
45901
46017
|
" lotics knowledge get <id> [-o <file.md>]",
|
|
45902
46018
|
" Fetch a doc's content (to a file, or stdout;",
|
|
45903
46019
|
" --json for the full doc)",
|
|
45904
|
-
" lotics knowledge update <id> [--from <file.md> | --content <str>] [--name <n>] [--description <d>]",
|
|
45905
|
-
" Update a doc \u2014 sends only the fields you pass",
|
|
46020
|
+
" lotics knowledge update <id> [--from <file.md> | --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]",
|
|
46021
|
+
" Update a doc \u2014 sends only the fields you pass;",
|
|
46022
|
+
" --tags REPLACES the doc's labels",
|
|
46023
|
+
" lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]",
|
|
46024
|
+
" Re-label a set of docs; adds/removes apply to",
|
|
46025
|
+
" each doc's own labels",
|
|
46026
|
+
" lotics knowledge hide <id...> Take docs out of circulation \u2014 gone from the",
|
|
46027
|
+
" Library list and from what the assistant browses",
|
|
46028
|
+
" and searches, still readable by id",
|
|
46029
|
+
" lotics knowledge unhide <id...> Put them back",
|
|
45906
46030
|
" lotics knowledge rm <id> Archive a doc"
|
|
45907
46031
|
]
|
|
45908
46032
|
},
|
|
@@ -45928,6 +46052,9 @@ var COMMANDS = [
|
|
|
45928
46052
|
aliases: ["upload", "download", "preview"],
|
|
45929
46053
|
help: [
|
|
45930
46054
|
" lotics file upload <file|dir...> Upload files (alias: lotics upload)",
|
|
46055
|
+
" lotics file upload --stdin --as <name>",
|
|
46056
|
+
" Upload bytes you already hold, piped on stdin",
|
|
46057
|
+
" (--base64 for base64; --url <url> to fetch first)",
|
|
45931
46058
|
" lotics file download <file_id> Download a file by ID (alias: lotics download)",
|
|
45932
46059
|
" lotics file download record <record_id> <field_key>",
|
|
45933
46060
|
" Download all files on a record file field",
|
|
@@ -74389,10 +74516,16 @@ ${totalErrors} error${totalErrors === 1 ? "" : "s"} across ${results.length - pa
|
|
|
74389
74516
|
}
|
|
74390
74517
|
|
|
74391
74518
|
// src/args.ts
|
|
74519
|
+
function splitList(value2) {
|
|
74520
|
+
return (value2 ?? "").split(",").map((part) => part.trim()).filter((part) => part !== "");
|
|
74521
|
+
}
|
|
74392
74522
|
function parseArgs(argv) {
|
|
74393
74523
|
const flags = {
|
|
74394
74524
|
json: false,
|
|
74395
74525
|
force: false,
|
|
74526
|
+
stdin: false,
|
|
74527
|
+
base64: false,
|
|
74528
|
+
url: void 0,
|
|
74396
74529
|
fromVersion: void 0,
|
|
74397
74530
|
timeout: void 0,
|
|
74398
74531
|
output: void 0,
|
|
@@ -74404,6 +74537,10 @@ function parseArgs(argv) {
|
|
|
74404
74537
|
description: void 0,
|
|
74405
74538
|
from: void 0,
|
|
74406
74539
|
content: void 0,
|
|
74540
|
+
tags: void 0,
|
|
74541
|
+
add: void 0,
|
|
74542
|
+
remove: void 0,
|
|
74543
|
+
includeHidden: false,
|
|
74407
74544
|
timezone: void 0,
|
|
74408
74545
|
message: void 0,
|
|
74409
74546
|
session: void 0,
|
|
@@ -74430,6 +74567,15 @@ function parseArgs(argv) {
|
|
|
74430
74567
|
case "--force":
|
|
74431
74568
|
flags.force = true;
|
|
74432
74569
|
break;
|
|
74570
|
+
case "--stdin":
|
|
74571
|
+
flags.stdin = true;
|
|
74572
|
+
break;
|
|
74573
|
+
case "--base64":
|
|
74574
|
+
flags.base64 = true;
|
|
74575
|
+
break;
|
|
74576
|
+
case "--url":
|
|
74577
|
+
flags.url = argv[++i2];
|
|
74578
|
+
break;
|
|
74433
74579
|
case "--from-version":
|
|
74434
74580
|
flags.fromVersion = argv[++i2];
|
|
74435
74581
|
break;
|
|
@@ -74465,6 +74611,21 @@ function parseArgs(argv) {
|
|
|
74465
74611
|
case "--content":
|
|
74466
74612
|
flags.content = argv[++i2];
|
|
74467
74613
|
break;
|
|
74614
|
+
// Comma-separated AND repeatable, because a label can contain a space but
|
|
74615
|
+
// never a comma, and an agent scripting this will reach for whichever form
|
|
74616
|
+
// it happens to know. Both accumulate rather than the last winning.
|
|
74617
|
+
case "--tags":
|
|
74618
|
+
flags.tags = [...flags.tags ?? [], ...splitList(argv[++i2])];
|
|
74619
|
+
break;
|
|
74620
|
+
case "--add":
|
|
74621
|
+
flags.add = [...flags.add ?? [], ...splitList(argv[++i2])];
|
|
74622
|
+
break;
|
|
74623
|
+
case "--remove":
|
|
74624
|
+
flags.remove = [...flags.remove ?? [], ...splitList(argv[++i2])];
|
|
74625
|
+
break;
|
|
74626
|
+
case "--include-hidden":
|
|
74627
|
+
flags.includeHidden = true;
|
|
74628
|
+
break;
|
|
74468
74629
|
case "--timezone":
|
|
74469
74630
|
flags.timezone = argv[++i2];
|
|
74470
74631
|
break;
|
|
@@ -102402,6 +102563,13 @@ async function runDocxCommand(subcommand, toolArgs, restArgs) {
|
|
|
102402
102563
|
|
|
102403
102564
|
// src/knowledge.ts
|
|
102404
102565
|
import fs12 from "node:fs";
|
|
102566
|
+
|
|
102567
|
+
// ../shared/src/knowledge_tags.ts
|
|
102568
|
+
function isHiddenDoc(doc) {
|
|
102569
|
+
return doc.hidden_at != null;
|
|
102570
|
+
}
|
|
102571
|
+
|
|
102572
|
+
// src/knowledge.ts
|
|
102405
102573
|
function readBodyFile(filePath) {
|
|
102406
102574
|
if (!fs12.existsSync(filePath)) fail2(`File not found: ${filePath}`);
|
|
102407
102575
|
return fs12.readFileSync(filePath, "utf-8");
|
|
@@ -102420,37 +102588,46 @@ function unwrap(res) {
|
|
|
102420
102588
|
return res.result;
|
|
102421
102589
|
}
|
|
102422
102590
|
async function knowledgeList(client, flags) {
|
|
102423
|
-
const
|
|
102424
|
-
const payload = result;
|
|
102425
|
-
const results = Array.isArray(payload?.results) ? payload.results : [];
|
|
102591
|
+
const docs = await client.listKnowledgeDocs({ includeHidden: flags.includeHidden });
|
|
102426
102592
|
if (flags.json) {
|
|
102427
|
-
console.log(JSON.stringify(
|
|
102593
|
+
console.log(JSON.stringify(docs, null, 2));
|
|
102428
102594
|
return;
|
|
102429
102595
|
}
|
|
102430
|
-
if (
|
|
102431
|
-
console.error(
|
|
102596
|
+
if (docs.length === 0) {
|
|
102597
|
+
console.error(
|
|
102598
|
+
flags.includeHidden ? "No knowledge docs in this workspace." : "No knowledge docs in this workspace. Add --include-hidden if some may be out of circulation."
|
|
102599
|
+
);
|
|
102432
102600
|
return;
|
|
102433
102601
|
}
|
|
102434
|
-
const rows =
|
|
102435
|
-
|
|
102436
|
-
|
|
102437
|
-
|
|
102438
|
-
|
|
102439
|
-
|
|
102440
|
-
|
|
102441
|
-
|
|
102442
|
-
|
|
102602
|
+
const rows = docs.map((doc) => ({
|
|
102603
|
+
id: doc.id,
|
|
102604
|
+
name: doc.name,
|
|
102605
|
+
tags: doc.tags.join(", "),
|
|
102606
|
+
// Collapse whitespace so a multi-line description never breaks the row.
|
|
102607
|
+
description: doc.description.replace(/\s+/g, " ").trim(),
|
|
102608
|
+
// The shared predicate, not a comparison written here: this CLI is pinned
|
|
102609
|
+
// per version and runs against whatever backend a workspace is on, including
|
|
102610
|
+
// one that predates the field, and reading its absence as hidden would mark
|
|
102611
|
+
// every row.
|
|
102612
|
+
hidden: isHiddenDoc(doc)
|
|
102613
|
+
}));
|
|
102443
102614
|
const wId = Math.max("ID".length, ...rows.map((r) => r.id.length));
|
|
102444
102615
|
const wName = Math.max("NAME".length, ...rows.map((r) => r.name.length));
|
|
102616
|
+
const wTags = Math.max("TAGS".length, ...rows.map((r) => r.tags.length));
|
|
102445
102617
|
const pad = (s, n) => s.padEnd(n);
|
|
102446
|
-
console.log(`${pad("ID", wId)} ${pad("NAME", wName)} DESCRIPTION`);
|
|
102618
|
+
console.log(`${pad("ID", wId)} ${pad("NAME", wName)} ${pad("TAGS", wTags)} DESCRIPTION`);
|
|
102447
102619
|
for (const r of rows) {
|
|
102448
|
-
|
|
102620
|
+
const description = r.hidden ? `(hidden) ${r.description}`.trimEnd() : r.description;
|
|
102621
|
+
console.log(`${pad(r.id, wId)} ${pad(r.name, wName)} ${pad(r.tags, wTags)} ${description}`.trimEnd());
|
|
102449
102622
|
}
|
|
102450
102623
|
}
|
|
102451
102624
|
async function knowledgeCreate(client, flags) {
|
|
102452
102625
|
const name2 = flags.name;
|
|
102453
|
-
if (!name2)
|
|
102626
|
+
if (!name2) {
|
|
102627
|
+
fail2(
|
|
102628
|
+
"Usage: lotics knowledge create --name <name> [--description <d>] [--tags <a,b>] (--from <file.md> | --content <str>)"
|
|
102629
|
+
);
|
|
102630
|
+
}
|
|
102454
102631
|
const content = resolveBody(flags, true);
|
|
102455
102632
|
const result = unwrap(
|
|
102456
102633
|
await client.execute("create_knowledge", {
|
|
@@ -102458,7 +102635,11 @@ async function knowledgeCreate(client, flags) {
|
|
|
102458
102635
|
// The tool requires a description (empty string is accepted); the CLI
|
|
102459
102636
|
// treats it as optional and defaults to "".
|
|
102460
102637
|
description: flags.description ?? "",
|
|
102461
|
-
content
|
|
102638
|
+
content,
|
|
102639
|
+
// Filed at the moment it is made. Creating and then labelling is two round
|
|
102640
|
+
// trips and leaves the doc unfiled in between, which is when a corpus
|
|
102641
|
+
// acquires the untagged rows nobody goes back for.
|
|
102642
|
+
...flags.tags !== void 0 && { tags: flags.tags }
|
|
102462
102643
|
})
|
|
102463
102644
|
);
|
|
102464
102645
|
if (flags.json) {
|
|
@@ -102484,15 +102665,20 @@ async function knowledgeGet(client, id, flags) {
|
|
|
102484
102665
|
console.log(doc.content);
|
|
102485
102666
|
}
|
|
102486
102667
|
async function knowledgeUpdate(client, id, flags) {
|
|
102487
|
-
if (!id)
|
|
102668
|
+
if (!id) {
|
|
102669
|
+
fail2(
|
|
102670
|
+
"Usage: lotics knowledge update <id> [--from <file.md> | --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]"
|
|
102671
|
+
);
|
|
102672
|
+
}
|
|
102488
102673
|
const content = resolveBody(flags, false);
|
|
102489
|
-
if (content === void 0 && flags.name === void 0 && flags.description === void 0) {
|
|
102490
|
-
fail2("Nothing to update. Pass at least one of --from/--content, --name, or --
|
|
102674
|
+
if (content === void 0 && flags.name === void 0 && flags.description === void 0 && flags.tags === void 0) {
|
|
102675
|
+
fail2("Nothing to update. Pass at least one of --from/--content, --name, --description, or --tags.");
|
|
102491
102676
|
}
|
|
102492
102677
|
const args = { knowledge_doc_id: id };
|
|
102493
102678
|
if (content !== void 0) args.content = content;
|
|
102494
102679
|
if (flags.name !== void 0) args.name = flags.name;
|
|
102495
102680
|
if (flags.description !== void 0) args.description = flags.description;
|
|
102681
|
+
if (flags.tags !== void 0) args.tags = flags.tags;
|
|
102496
102682
|
const result = unwrap(await client.execute("update_knowledge", args));
|
|
102497
102683
|
if (flags.json) {
|
|
102498
102684
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -102509,21 +102695,64 @@ async function knowledgeRm(client, id, flags) {
|
|
|
102509
102695
|
}
|
|
102510
102696
|
console.error(`Deleted knowledge doc: ${id}`);
|
|
102511
102697
|
}
|
|
102698
|
+
function requireIds(ids, usage) {
|
|
102699
|
+
const parsed = ids.filter((id) => id !== void 0).flatMap((id) => id.split(",")).map((id) => id.trim()).filter((id) => id !== "");
|
|
102700
|
+
if (parsed.length === 0) fail2(usage);
|
|
102701
|
+
return parsed;
|
|
102702
|
+
}
|
|
102703
|
+
async function knowledgeTag(client, ids, flags) {
|
|
102704
|
+
const knowledge_doc_ids = requireIds(ids, "Usage: lotics knowledge tag <id...> [--add a,b] [--remove c,d]");
|
|
102705
|
+
if (flags.add === void 0 && flags.remove === void 0) {
|
|
102706
|
+
fail2("Pass --add <tags> and/or --remove <tags>.");
|
|
102707
|
+
}
|
|
102708
|
+
const updated = await client.bulkUpdateKnowledgeDocs({
|
|
102709
|
+
knowledge_doc_ids,
|
|
102710
|
+
...flags.add !== void 0 && { add_tags: flags.add },
|
|
102711
|
+
...flags.remove !== void 0 && { remove_tags: flags.remove }
|
|
102712
|
+
});
|
|
102713
|
+
if (flags.json) {
|
|
102714
|
+
console.log(JSON.stringify(updated, null, 2));
|
|
102715
|
+
return;
|
|
102716
|
+
}
|
|
102717
|
+
for (const doc of updated) {
|
|
102718
|
+
console.error(`${doc.id} ${doc.tags.length > 0 ? doc.tags.join(", ") : "(no tags)"}`);
|
|
102719
|
+
}
|
|
102720
|
+
}
|
|
102721
|
+
async function knowledgeSetHidden(client, ids, hidden, flags) {
|
|
102722
|
+
const verb = hidden ? "hide" : "unhide";
|
|
102723
|
+
const knowledge_doc_ids = requireIds(ids, `Usage: lotics knowledge ${verb} <id...>`);
|
|
102724
|
+
const updated = await client.bulkUpdateKnowledgeDocs({ knowledge_doc_ids, hidden });
|
|
102725
|
+
if (flags.json) {
|
|
102726
|
+
console.log(JSON.stringify(updated, null, 2));
|
|
102727
|
+
return;
|
|
102728
|
+
}
|
|
102729
|
+
console.error(
|
|
102730
|
+
hidden ? `Hid ${updated.length} doc(s). They are out of the assistant's corpus; lotics knowledge list --include-hidden still finds them.` : `Restored ${updated.length} doc(s) to the corpus.`
|
|
102731
|
+
);
|
|
102732
|
+
}
|
|
102512
102733
|
function printKnowledgeHelp() {
|
|
102513
102734
|
console.error(`Lotics knowledge commands \u2014 manage the AI's rulebook docs.
|
|
102514
102735
|
|
|
102515
|
-
lotics knowledge list [--json]
|
|
102516
|
-
Catalog every doc you can use (id, name, description).
|
|
102517
|
-
lotics knowledge create --name <n> [--description <d>] (--from <file.md> | --content <str>) [--json]
|
|
102736
|
+
lotics knowledge list [--include-hidden] [--json]
|
|
102737
|
+
Catalog every doc you can use (id, name, tags, description).
|
|
102738
|
+
lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> | --content <str>) [--json]
|
|
102518
102739
|
Create a doc; the body comes from a file or an inline string. Prints the new id.
|
|
102519
102740
|
lotics knowledge get <id> [-o <file.md>] [--json]
|
|
102520
102741
|
Fetch a doc's content \u2014 to <file.md>, or stdout. --json prints the full doc.
|
|
102521
102742
|
lotics knowledge update <id> [--from <file.md> | --content <str>] [--name <n>] [--description <d>] [--json]
|
|
102522
102743
|
Update a doc; sends only the fields you pass.
|
|
102744
|
+
lotics knowledge tag <id...> [--add a,b] [--remove c,d] [--json]
|
|
102745
|
+
Re-label a set of docs. Adds and removes are a DIFF applied to each doc's
|
|
102746
|
+
own labels \u2014 use update --tags to state one doc's complete set.
|
|
102747
|
+
lotics knowledge hide <id...> [--json]
|
|
102748
|
+
Take docs out of circulation: gone from the Library's list and from what
|
|
102749
|
+
the assistant browses and searches, still readable by id, nothing deleted.
|
|
102750
|
+
lotics knowledge unhide <id...> [--json]
|
|
102751
|
+
Put them back.
|
|
102523
102752
|
lotics knowledge rm <id> [--json]
|
|
102524
102753
|
Archive a doc.`);
|
|
102525
102754
|
}
|
|
102526
|
-
async function runKnowledgeCommand(client, subcommand, toolArgs, flags) {
|
|
102755
|
+
async function runKnowledgeCommand(client, subcommand, toolArgs, flags, restArgs = []) {
|
|
102527
102756
|
switch (subcommand) {
|
|
102528
102757
|
case "list":
|
|
102529
102758
|
return knowledgeList(client, flags);
|
|
@@ -102533,6 +102762,12 @@ async function runKnowledgeCommand(client, subcommand, toolArgs, flags) {
|
|
|
102533
102762
|
return knowledgeGet(client, toolArgs, flags);
|
|
102534
102763
|
case "update":
|
|
102535
102764
|
return knowledgeUpdate(client, toolArgs, flags);
|
|
102765
|
+
case "tag":
|
|
102766
|
+
return knowledgeTag(client, [toolArgs, ...restArgs], flags);
|
|
102767
|
+
case "hide":
|
|
102768
|
+
return knowledgeSetHidden(client, [toolArgs, ...restArgs], true, flags);
|
|
102769
|
+
case "unhide":
|
|
102770
|
+
return knowledgeSetHidden(client, [toolArgs, ...restArgs], false, flags);
|
|
102536
102771
|
case "rm":
|
|
102537
102772
|
return knowledgeRm(client, toolArgs, flags);
|
|
102538
102773
|
default:
|
|
@@ -102791,7 +103026,10 @@ FLAGS
|
|
|
102791
103026
|
--json Full JSON output (default is human-readable text)
|
|
102792
103027
|
--timeout <ms> Timeout for tool execution (default: 60000)
|
|
102793
103028
|
-o <path> Output dir for downloads
|
|
102794
|
-
--as <name> Override upload filename
|
|
103029
|
+
--as <name> Override upload filename (required with --stdin/--base64)
|
|
103030
|
+
--stdin Upload raw bytes piped on stdin instead of a path
|
|
103031
|
+
--base64 Upload base64 piped on stdin (decoded strictly)
|
|
103032
|
+
--url <url> Fetch a URL and upload what it returns
|
|
102795
103033
|
--api-key <key> One-off API key (overrides saved config + env)
|
|
102796
103034
|
--workspace <id> One-off workspace override (alias: -w)
|
|
102797
103035
|
--view-as <id> Admin "View as": run every request as this member, so
|
|
@@ -103394,8 +103632,12 @@ async function main() {
|
|
|
103394
103632
|
console.error('Run "lotics tools" to see available tools.');
|
|
103395
103633
|
process.exit(1);
|
|
103396
103634
|
}
|
|
103397
|
-
if (command === "upload" && !subcommand) {
|
|
103398
|
-
console.error("Usage:
|
|
103635
|
+
if (command === "upload" && !subcommand && !flags.stdin && !flags.base64 && !flags.url) {
|
|
103636
|
+
console.error("Usage:");
|
|
103637
|
+
console.error(" lotics upload <file|dir...> [--as <name>]");
|
|
103638
|
+
console.error(" lotics upload --stdin --as <name> Raw bytes on stdin");
|
|
103639
|
+
console.error(" lotics upload --base64 --as <name> Base64 on stdin");
|
|
103640
|
+
console.error(" lotics upload --url <url> [--as <name>]");
|
|
103399
103641
|
console.error("Uploads files and returns their file_ids. Directories expand to their immediate files.");
|
|
103400
103642
|
process.exit(1);
|
|
103401
103643
|
}
|
|
@@ -103556,7 +103798,7 @@ Available workspaces:`);
|
|
|
103556
103798
|
applyAppManifestWorkspace(ctx, command, subcommand, appPathArg, flags);
|
|
103557
103799
|
await resolveWorkspace(client, ctx);
|
|
103558
103800
|
if (command === "knowledge") {
|
|
103559
|
-
await runKnowledgeCommand(client, subcommand, toolArgs, flags);
|
|
103801
|
+
await runKnowledgeCommand(client, subcommand, toolArgs, flags, restArgs);
|
|
103560
103802
|
return;
|
|
103561
103803
|
}
|
|
103562
103804
|
if (command === "app") {
|
|
@@ -103799,19 +104041,39 @@ ${JSON.stringify(info.input_schema, null, 2)}`);
|
|
|
103799
104041
|
return;
|
|
103800
104042
|
}
|
|
103801
104043
|
if (command === "upload") {
|
|
103802
|
-
const
|
|
103803
|
-
|
|
103804
|
-
|
|
103805
|
-
console.error("No files found in the specified paths.");
|
|
104044
|
+
const sources = [flags.stdin && "--stdin", flags.base64 && "--base64", flags.url && "--url"].filter(Boolean);
|
|
104045
|
+
if (sources.length > 1) {
|
|
104046
|
+
console.error(`Pick one source: ${sources.join(", ")} were all given.`);
|
|
103806
104047
|
process.exit(1);
|
|
103807
104048
|
}
|
|
103808
|
-
|
|
103809
|
-
|
|
103810
|
-
|
|
104049
|
+
let upload;
|
|
104050
|
+
if (sources.length === 1) {
|
|
104051
|
+
const positional = [subcommand, toolArgs, ...restArgs].filter((v) => v !== void 0);
|
|
104052
|
+
if (positional.length > 0) {
|
|
104053
|
+
console.error(`${sources[0]} reads the bytes itself \u2014 drop the path argument (${positional.join(", ")}).`);
|
|
104054
|
+
process.exit(1);
|
|
104055
|
+
}
|
|
104056
|
+
const resolved = await resolveUploadSource(String(sources[0]), flags);
|
|
104057
|
+
if ("error" in resolved) {
|
|
104058
|
+
console.error(resolved.error);
|
|
104059
|
+
process.exit(1);
|
|
104060
|
+
}
|
|
104061
|
+
upload = await client.uploadFileBytes([resolved]);
|
|
104062
|
+
} else {
|
|
104063
|
+
const rawPaths = [subcommand, ...toolArgs ? [toolArgs] : [], ...restArgs];
|
|
104064
|
+
const filePaths = resolveUploadPaths(rawPaths);
|
|
104065
|
+
if (filePaths.length === 0) {
|
|
104066
|
+
console.error("No files found in the specified paths.");
|
|
104067
|
+
process.exit(1);
|
|
104068
|
+
}
|
|
104069
|
+
if (flags.as && filePaths.length > 1) {
|
|
104070
|
+
console.error("Cannot use --as with multiple files.");
|
|
104071
|
+
process.exit(1);
|
|
104072
|
+
}
|
|
104073
|
+
upload = await client.uploadFiles(filePaths, {
|
|
104074
|
+
filenames: flags.as ? [flags.as] : void 0
|
|
104075
|
+
});
|
|
103811
104076
|
}
|
|
103812
|
-
const upload = await client.uploadFiles(filePaths, {
|
|
103813
|
-
filenames: flags.as ? [flags.as] : void 0
|
|
103814
|
-
});
|
|
103815
104077
|
if (upload.files.length === 0 && upload.errors.length > 0) {
|
|
103816
104078
|
console.error(`Upload failed: ${upload.errors.map((e) => `${e.filename}: ${e.error}`).join(", ")}`);
|
|
103817
104079
|
process.exit(1);
|
package/dist/src/client.d.ts
CHANGED
|
@@ -86,19 +86,24 @@ export interface ToolInfo {
|
|
|
86
86
|
* as `expected_content_sha`; the `update_knowledge` TOOL CASes internally, so a
|
|
87
87
|
* CLI caller never needs to pass it.
|
|
88
88
|
*/
|
|
89
|
-
|
|
89
|
+
/** A doc's metadata, as every listing serves it — no body. */
|
|
90
|
+
export interface KnowledgeDocSummary {
|
|
90
91
|
id: string;
|
|
91
92
|
workspace_id: string;
|
|
92
93
|
name: string;
|
|
93
94
|
description: string;
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/** How the doc is classified. Order-insensitive; empty means untagged. */
|
|
96
|
+
tags: string[];
|
|
97
|
+
/** When the doc was taken out of circulation, or null while it is in use. */
|
|
98
|
+
hidden_at: string | null;
|
|
97
99
|
content_sha: string | null;
|
|
98
100
|
files: unknown[];
|
|
99
101
|
created_at: string;
|
|
100
102
|
updated_at: string;
|
|
101
103
|
}
|
|
104
|
+
export interface KnowledgeDocDetail extends KnowledgeDocSummary {
|
|
105
|
+
content: string;
|
|
106
|
+
}
|
|
102
107
|
export interface FileUploadResult {
|
|
103
108
|
files: Array<{
|
|
104
109
|
id: string;
|
|
@@ -228,6 +233,24 @@ export declare class LoticsClient {
|
|
|
228
233
|
* the file-model and legacy parked-column rows. Mirrors GET /v1/knowledge_docs/{id}.
|
|
229
234
|
*/
|
|
230
235
|
getKnowledgeDoc(knowledge_doc_id: string): Promise<KnowledgeDocDetail>;
|
|
236
|
+
/**
|
|
237
|
+
* Every doc's metadata — REST rather than the `list_knowledge` tool, because
|
|
238
|
+
* this is the surface a PERSON manages the corpus from and the tool is bound
|
|
239
|
+
* to what the assistant may browse. That distinction is the whole point of
|
|
240
|
+
* `include_hidden`: a hidden doc is out of the assistant's corpus by design,
|
|
241
|
+
* and someone still has to be able to find it in order to put it back.
|
|
242
|
+
*/
|
|
243
|
+
listKnowledgeDocs(opts?: {
|
|
244
|
+
includeHidden?: boolean;
|
|
245
|
+
}): Promise<KnowledgeDocSummary[]>;
|
|
246
|
+
/** Apply one metadata change to a set of docs. Tags are an add/remove DIFF —
|
|
247
|
+
* see the endpoint's own note on why a replacement is the wrong shape here. */
|
|
248
|
+
bulkUpdateKnowledgeDocs(body: {
|
|
249
|
+
knowledge_doc_ids: string[];
|
|
250
|
+
add_tags?: string[];
|
|
251
|
+
remove_tags?: string[];
|
|
252
|
+
hidden?: boolean;
|
|
253
|
+
}): Promise<KnowledgeDocSummary[]>;
|
|
231
254
|
getApp(app_id: string): Promise<{
|
|
232
255
|
id: string;
|
|
233
256
|
name: string;
|
|
@@ -1112,4 +1135,15 @@ export declare class LoticsClient {
|
|
|
1112
1135
|
uploadFiles(filePaths: string[], options?: {
|
|
1113
1136
|
filenames?: string[];
|
|
1114
1137
|
}): Promise<FileUploadResult>;
|
|
1138
|
+
/**
|
|
1139
|
+
* Store files from bytes the caller already holds — the path for a caller that
|
|
1140
|
+
* never had them on disk (an email attachment decoded in memory, a generated
|
|
1141
|
+
* document, a fetched URL). `uploadFiles` is this with a read in front, so
|
|
1142
|
+
* both routes hit one endpoint and one mime-derivation rule.
|
|
1143
|
+
*/
|
|
1144
|
+
uploadFileBytes(items: {
|
|
1145
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
1146
|
+
filename: string;
|
|
1147
|
+
mimeType?: string;
|
|
1148
|
+
}[]): Promise<FileUploadResult>;
|
|
1115
1149
|
}
|
package/dist/src/client.js
CHANGED
|
@@ -199,6 +199,22 @@ export class LoticsClient {
|
|
|
199
199
|
async getKnowledgeDoc(knowledge_doc_id) {
|
|
200
200
|
return this.request("GET", `/v1/knowledge_docs/${encodeURIComponent(knowledge_doc_id)}`);
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Every doc's metadata — REST rather than the `list_knowledge` tool, because
|
|
204
|
+
* this is the surface a PERSON manages the corpus from and the tool is bound
|
|
205
|
+
* to what the assistant may browse. That distinction is the whole point of
|
|
206
|
+
* `include_hidden`: a hidden doc is out of the assistant's corpus by design,
|
|
207
|
+
* and someone still has to be able to find it in order to put it back.
|
|
208
|
+
*/
|
|
209
|
+
async listKnowledgeDocs(opts) {
|
|
210
|
+
const qs = opts?.includeHidden ? "?include_hidden=true" : "";
|
|
211
|
+
return this.request("GET", `/v1/knowledge_docs${qs}`);
|
|
212
|
+
}
|
|
213
|
+
/** Apply one metadata change to a set of docs. Tags are an add/remove DIFF —
|
|
214
|
+
* see the endpoint's own note on why a replacement is the wrong shape here. */
|
|
215
|
+
async bulkUpdateKnowledgeDocs(body) {
|
|
216
|
+
return this.request("PATCH", "/v1/knowledge_docs", body);
|
|
217
|
+
}
|
|
202
218
|
// --- Apps ---
|
|
203
219
|
async getApp(app_id) {
|
|
204
220
|
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}`);
|
|
@@ -846,14 +862,40 @@ export class LoticsClient {
|
|
|
846
862
|
}));
|
|
847
863
|
}
|
|
848
864
|
async uploadFiles(filePaths, options) {
|
|
849
|
-
|
|
865
|
+
// Sequential, not `Promise.all`: `lotics upload <dir>` expands a directory to
|
|
866
|
+
// every file in it, and reading them all at once opens one handle per file —
|
|
867
|
+
// EMFILE on a large directory. The upload itself is a single request either
|
|
868
|
+
// way, so concurrency here buys nothing.
|
|
869
|
+
const items = [];
|
|
850
870
|
for (let i = 0; i < filePaths.length; i++) {
|
|
851
871
|
const absolutePath = path.resolve(filePaths[i]);
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
872
|
+
items.push({
|
|
873
|
+
bytes: await fs.promises.readFile(absolutePath),
|
|
874
|
+
filename: options?.filenames?.[i] ?? path.basename(absolutePath),
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
return this.uploadFileBytes(items);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Store files from bytes the caller already holds — the path for a caller that
|
|
881
|
+
* never had them on disk (an email attachment decoded in memory, a generated
|
|
882
|
+
* document, a fetched URL). `uploadFiles` is this with a read in front, so
|
|
883
|
+
* both routes hit one endpoint and one mime-derivation rule.
|
|
884
|
+
*/
|
|
885
|
+
async uploadFileBytes(
|
|
886
|
+
// `Uint8Array<ArrayBuffer>`, not a bare `Uint8Array`: `Blob` takes a
|
|
887
|
+
// `BufferSource`, which excludes a `SharedArrayBuffer`-backed view. Every
|
|
888
|
+
// real source here (`readFile`, `Buffer.concat`, `response.arrayBuffer()`)
|
|
889
|
+
// is already ArrayBuffer-backed, so the narrower type states the
|
|
890
|
+
// requirement rather than casting it away at the call site.
|
|
891
|
+
items) {
|
|
892
|
+
const formData = new FormData();
|
|
893
|
+
for (const item of items) {
|
|
894
|
+
const mimeType = item.mimeType ?? getMimeType(item.filename);
|
|
895
|
+
// `Blob` copies a BufferSource by the VIEW's offset+length, so passing a
|
|
896
|
+
// pooled `Buffer` from `readFile` straight in is correct — no defensive
|
|
897
|
+
// re-copy, which would double peak memory on every upload.
|
|
898
|
+
formData.append("file", new Blob([item.bytes], { type: mimeType }), item.filename);
|
|
857
899
|
}
|
|
858
900
|
const url = `${this.baseUrl}/v1/files`;
|
|
859
901
|
const response = await fetch(url, {
|
package/docs/cli_reference.md
CHANGED
|
@@ -22,12 +22,14 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
22
22
|
| `lotics tools <name>` | Full description + JSON Schema for one tool |
|
|
23
23
|
| `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or piped stdin (`cat args.json \| lotics run <tool>`) — both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). |
|
|
24
24
|
| `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |
|
|
25
|
-
| `lotics upload <file\|dir...>` | Upload files/directories via multipart POST to /v1/files |
|
|
25
|
+
| `lotics upload <file\|dir...>` · `--stdin` · `--base64` · `--url <url>` | Upload files/directories via multipart POST to /v1/files. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** — an attachment decoded in memory, a generated document, a signed download link — each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY — `Buffer.from(s, "base64")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |
|
|
26
26
|
| `lotics file download <file_id> [-o <dir>]` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` → fetch the presigned URL, saving under the stored filename (from the response's `Content-Disposition`) into `-o` (a **DIRECTORY** — note this is distinct from `lotics file preview`'s `-o`, which is a FILE path), else cwd. `lotics file download record <record_id> <field_key>` pulls every file on a record's file field. |
|
|
27
|
-
| `lotics knowledge list` |
|
|
28
|
-
| `lotics knowledge create --name <n> [--description <d>] (--from <file.md> \| --content <str>)` | Read the body client-side (a file XOR an inline string — exactly one required), then call `create_knowledge` with `{ name, description, content }` (description defaults to `""`). Prints the new id to stdout. Large files ride the POST body fine. |
|
|
27
|
+
| `lotics knowledge list [--include-hidden]` | `GET /v1/knowledge_docs` — a table of id, name, tags, description (`--json` for the docs). **REST, not the `list_knowledge` tool**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. |
|
|
28
|
+
| `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \| --content <str>)` | Read the body client-side (a file XOR an inline string — exactly one required), then call `create_knowledge` with `{ name, description, content, tags? }` (description defaults to `""`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |
|
|
29
29
|
| `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) → the doc with its **hydrated `content`** (the one content-read path for a non-sandbox client; works for file-model AND legacy parked-column rows). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |
|
|
30
|
-
| `lotics knowledge update <id> [--from <file.md> \| --content <str>] [--name <n>] [--description <d>]` | Call `update_knowledge` with **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). At least one field required; --from and --content are mutually exclusive. |
|
|
30
|
+
| `lotics knowledge update <id> [--from <file.md> \| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `update_knowledge` with **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). `--tags` REPLACES the doc's label set — the single-doc form, where the caller is looking at one doc and can state what it should carry. At least one field required; --from and --content are mutually exclusive. |
|
|
31
|
+
| `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` — one transaction over the whole set. A **DIFF applied to each doc's own labels**, never a replacement: the docs named on one command line carry different labels, so one array across them would strip whatever the others were filed under. Removal matches case-insensitively; adding a label a doc already carries writes nothing. Ids may be separate arguments or comma-separated. At least one of --add/--remove required. |
|
|
32
|
+
| `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** — the Library's list, `list_knowledge`, and the corpus `grep_knowledge` searches — while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as "everything". |
|
|
31
33
|
| `lotics knowledge rm <id>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |
|
|
32
34
|
| `lotics app create <name> [path]` | Scaffold a Vite+React+TS custom-code app project; POST /v1/apps; npm install; vite build; upload as v1 |
|
|
33
35
|
| `lotics app pull <app_id> [path]` | Download source archive from R2 (presigned), extract, npm install, stamp package.json's `lotics` field. With no `[path]`: refresh the cwd IN PLACE when it's already this app's own project (its manifest `app_id` matches — the documented `cd <app> && lotics app pull` flow), else clone into an `<name>/` subdir. **A pull never overwrites a file that differs from what it is about to write** — it writes only what is ABSENT or already identical, keeps the rest, and reports which files it kept plus the commands that close the gap. The same rule covers `src/workflows/<alias>.ts` and `src/agents/<alias>.md`, so an unpushed body or prompt survives too. Those two are written from the LIVE App row (`apps.workflows` / `apps.agents`), which owns them, and the archive's own copy of them is deliberately SKIPPED on extract: a deploy tars the whole source directory, so the tarball holds a deploy-time snapshot that is stale for anything authored since. The comparison is against the app's own content, not git, so it holds for a project that was never a repo. `--force` takes the app's copy and DISCARDS local edits; there is no other way to lose them. **When a pull ACROSS versions keeps files, the manifest is left on the OLDER of the two versions** — the tree is then part one and part the other, and claiming the newer would make `deploy`'s `prev_version_id` check pass and ship a half-and-half bundle. Older, not "the one it had": `--from-version` pulls a deliberately old revision, so the version it had is the NEWER side, and holding that would match what the server serves and let the old source ship. Either way a deploy from that tree is refused until you reconcile the listed files by hand and pull again, or take the app's copy with `--force`. The report says which version each side is on, because a kept file is your unshipped work when the project was already current and merely the OLD version when it was behind — and nothing in a byte comparison can tell those apart. **`--from-version <apv_…>`** pulls an OLDER revision instead of the current one (`lotics app versions` lists the ids) — point it at a NEW path to read a previous revision without disturbing the project you are in. The manifest records the version actually written, never the live pointer, so a deploy from that checkout is refused by the version guard rather than shipping old source over newer. — `workflows` and `agents` are sourced from the live App row (NOT the archived manifest), so `set_app_workflow` / `set_app_agent` authoring survives the pull. Regenerates `.lotics/app_{workflows,queries,agents}.d.ts` so `useWorkflow` / `useQuery` / `useAgentRun` stay typed, AND — AFTER `npm install`, so `node_modules/@lotics/ui` actually exists to read — `.lotics/react_native.d.ts` (the kit's web-only ViewStyle/TextStyle augmentation) and `.lotics/tsconfig.link.json`'s peer pins; a pulled project's own `tsc` used to fail the moment it used a component relying on the augmentation (Dialog, Picker, TimePicker, …) until `app codegen` was run by hand, because nothing had regenerated either one after install populated node_modules. AND the runtime `.lotics/app_fields.ts` (the same linked-vs-bespoke branch `app codegen` runs, off the app row already fetched — see that row for the two forms). That one is not optional: `app deploy` tars source with `--exclude=.lotics`, so no archive can carry it, and a pulled project whose `src/` imports `F`/`OPT` would fail to build with `Could not resolve "../../.lotics/app_fields"` until `app codegen` was run by hand. The write NAMES the form and the reason, because an in-place pull can FLIP a project between them (`opctl app publish` links an origin, `package eject` unlinks it) and that changes what the module does at load. Skipped under `--view-as` (the schema is read as that member and silently drops tables they cannot see — a narrowed `F` map compiles and then throws at runtime, worse than the missing module). A binding/schema fetch failure is non-fatal and names the right recovery for what is on disk: an existing file is kept, an ABSENT one warns about the build error and points at `app codegen`. Pull GENERATES but never RECONCILES `.lotics/` — deleting a companion whose alias the manifest no longer declares is `app codegen`'s alone, since pull's authority is the server's alias set and a declared-but-not-yet-`set` alias is supported. Also writes one `src/workflows/<alias>.ts` per bound workflow (faithful body from `get_app_workflow`) and one `src/agents/<alias>.md` per bound agent (its instructions, straight off the live row) — so the prose an author actually edits lives in a file, and pull always overwrites it from live, leaving no second copy to drift. A legacy workflow alias with no rendered source, or an agent with no instructions, warns and is skipped. The stamped `lotics.agents` map carries the TYPED half only (`inputs`/`outputs`/`tool_names`/`model_tier`/…) — an agent's prose lives solely in its `.md`, so there is never a second local copy to desync; a stale `instructions` left by an older CLI is inert and disappears on the next pull |
|
package/docs/knowledge_docs.md
CHANGED
|
@@ -24,6 +24,7 @@ the doc, the doc is noise.
|
|
|
24
24
|
```bash
|
|
25
25
|
lotics knowledge create --name "Shipping tariffs" \
|
|
26
26
|
--description "HS-coded rates; searchable by lane and code" \
|
|
27
|
+
--tags "HS,nhập khẩu" \ # file it as you make it — see Classifying, below
|
|
27
28
|
--from ./tariffs.md # the body is a Markdown file; or --content '<inline>'
|
|
28
29
|
```
|
|
29
30
|
|
|
@@ -143,13 +144,53 @@ lotics knowledge update kdc_... --from ./tariffs.md # replace the body (--name
|
|
|
143
144
|
```
|
|
144
145
|
|
|
145
146
|
Send only the fields you're changing. `--from` / `--content` replaces the body; `--name` /
|
|
146
|
-
`--description` change metadata. `update_knowledge` resolves concurrency **internally** — it
|
|
147
|
+
`--description` / `--tags` change metadata. `update_knowledge` resolves concurrency **internally** — it
|
|
147
148
|
re-reads the current content pointer and version-chains the new body — so there is no version
|
|
148
149
|
token to pass from the CLI. (The chat agent may instead send an `edits` array — anchored
|
|
149
150
|
replace / insert / append — for a surgical change; see `lotics tools update_knowledge`.) Refine
|
|
150
151
|
structure as you learn what users actually ask: add the synonym that failed to match, split a
|
|
151
152
|
line that was too coarse, restate a term the heading was carrying.
|
|
152
153
|
|
|
154
|
+
## Classifying a corpus — tags
|
|
155
|
+
|
|
156
|
+
A doc carries a SET of labels, not a folder. Order does not matter, a doc can wear several, and
|
|
157
|
+
the vocabulary is whatever the docs themselves use — a tag stops existing the moment nothing
|
|
158
|
+
carries it.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
lotics knowledge update kdc_... --tags "HS,nhập khẩu" # ONE doc: state its complete set
|
|
162
|
+
lotics knowledge tag kdc_a kdc_b --add HS --remove draft # MANY docs: a diff applied to each
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The split is deliberate. Looking at one doc you know what it should carry, so `--tags` replaces.
|
|
166
|
+
Across a set you do not — the docs carry different labels — so `tag` adds and removes against
|
|
167
|
+
each doc's own set, and never states one classification for all of them. Removal ignores case;
|
|
168
|
+
adding a label a doc already carries changes nothing.
|
|
169
|
+
|
|
170
|
+
An agent narrows by tag rather than reading the whole corpus:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
lotics tools list_knowledge # takes an optional tag
|
|
174
|
+
lotics tools grep_knowledge # same
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Taking a doc out of circulation — `hide`
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
lotics knowledge hide kdc_... # superseded, draft, source material
|
|
181
|
+
lotics knowledge list --include-hidden # find it again
|
|
182
|
+
lotics knowledge unhide kdc_... # put it back
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A hidden doc is out of every **listing**: it is gone from the Library's list, from
|
|
186
|
+
`list_knowledge`, and from the corpus `grep_knowledge` searches, so nothing finds it by browsing.
|
|
187
|
+
It is not deleted, not unshared, and still **reads when addressed by id** — `read_knowledge` with
|
|
188
|
+
its id, a code run staging it, an app agent that declares it. That is the point: hiding is a
|
|
189
|
+
statement about discovery, so it cannot silently break an app that depends on a doc by name.
|
|
190
|
+
|
|
191
|
+
Reach for it instead of `rm` whenever the material still matters: last year's tariff schedule, a
|
|
192
|
+
handbook a newer one replaced, the raw source a curated doc was written from.
|
|
193
|
+
|
|
153
194
|
## Package-managed knowledge
|
|
154
195
|
|
|
155
196
|
A knowledge doc can also be delivered as part of a **content package** — a versioned corpus of
|
|
@@ -161,12 +202,15 @@ same either way; the package layer just manages distribution and version pinning
|
|
|
161
202
|
## Reaching the tools
|
|
162
203
|
|
|
163
204
|
```bash
|
|
164
|
-
lotics knowledge list # catalog: id, name, description
|
|
205
|
+
lotics knowledge list # catalog: id, name, tags, description
|
|
165
206
|
lotics knowledge get kdc_... -o doc.md # read a body to a file (omit -o for stdout)
|
|
166
207
|
lotics tools update_knowledge # full input schema for any knowledge tool
|
|
167
208
|
```
|
|
168
209
|
|
|
169
210
|
The Knowledge category covers `list_knowledge`, `create_knowledge`, `update_knowledge`, and
|
|
170
|
-
`delete_knowledge` — fronted by the `lotics knowledge list | create | get | update |
|
|
171
|
-
commands
|
|
211
|
+
`delete_knowledge` — fronted by the `lotics knowledge list | create | get | update | tag | hide |
|
|
212
|
+
unhide | rm` commands, and reachable over MCP as well, so a corpus can be authored from whichever
|
|
213
|
+
surface you already work in. `list`, `tag`, `hide` and `unhide` go over REST rather than a tool, because
|
|
214
|
+
they are a person's view of the corpus: the tools answer what the ASSISTANT may browse, and a
|
|
215
|
+
hidden doc is out of that set by definition. Sharing a doc to other members is `share_resource` / `unshare_resource` (category
|
|
172
216
|
**Admin**).
|