@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 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 formData = new FormData();
45151
+ const items = [];
45136
45152
  for (let i2 = 0; i2 < filePaths.length; i2++) {
45137
45153
  const absolutePath = path2.resolve(filePaths[i2]);
45138
- const buffer = await fs2.promises.readFile(absolutePath);
45139
- const filename = options?.filenames?.[i2] ?? path2.basename(absolutePath);
45140
- const mimeType = getMimeType(filename);
45141
- const blob = new Blob([buffer], { type: mimeType });
45142
- formData.append("file", blob, filename);
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 List knowledge docs (id, name, description)",
45898
- " lotics knowledge create --name <n> [--description <d>] --from <file.md>",
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 result = unwrap(await client.execute("list_knowledge", {}));
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(results, null, 2));
102593
+ console.log(JSON.stringify(docs, null, 2));
102428
102594
  return;
102429
102595
  }
102430
- if (results.length === 0) {
102431
- console.error("No knowledge docs in this workspace.");
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 = results.map((r) => {
102435
- const doc = r;
102436
- return {
102437
- id: typeof doc.id === "string" ? doc.id : "",
102438
- name: typeof doc.name === "string" ? doc.name : "",
102439
- // Collapse whitespace so a multi-line description never breaks the row.
102440
- description: typeof doc.description === "string" ? doc.description.replace(/\s+/g, " ").trim() : ""
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
- console.log(`${pad(r.id, wId)} ${pad(r.name, wName)} ${r.description}`.trimEnd());
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) fail2("Usage: lotics knowledge create --name <name> [--description <d>] (--from <file.md> | --content <str>)");
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) fail2("Usage: lotics knowledge update <id> [--from <file.md> | --content <str>] [--name <n>] [--description <d>]");
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 --description.");
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: lotics upload <file|dir...> [--as <name>]");
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 rawPaths = [subcommand, ...toolArgs ? [toolArgs] : [], ...restArgs];
103803
- const filePaths = resolveUploadPaths(rawPaths);
103804
- if (filePaths.length === 0) {
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
- if (flags.as && filePaths.length > 1) {
103809
- console.error("Cannot use --as with multiple files.");
103810
- process.exit(1);
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);
@@ -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
- export interface KnowledgeDocDetail {
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
- /** Folder address; "" is root. */
95
- path: string;
96
- content: string;
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
  }
@@ -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
- const formData = new FormData();
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
- const buffer = await fs.promises.readFile(absolutePath);
853
- const filename = options?.filenames?.[i] ?? path.basename(absolutePath);
854
- const mimeType = getMimeType(filename);
855
- const blob = new Blob([buffer], { type: mimeType });
856
- formData.append("file", blob, filename);
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, {
@@ -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` | List knowledge docs via the `list_knowledge` tool — a table of id, name, description (`--json` for the raw results array). |
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 |
@@ -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 | rm`
171
- commands. Sharing a doc to other members is `share_resource` / `unshare_resource` (category
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**).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.150.2",
3
+ "version": "0.152.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {