bopodev-api 0.1.35 → 0.1.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -21,9 +21,9 @@
21
21
  "ws": "^8.19.0",
22
22
  "yaml": "^2.8.3",
23
23
  "zod": "^4.1.5",
24
- "bopodev-agent-sdk": "0.1.35",
25
- "bopodev-db": "0.1.35",
26
- "bopodev-contracts": "0.1.35"
24
+ "bopodev-agent-sdk": "0.1.37",
25
+ "bopodev-contracts": "0.1.37",
26
+ "bopodev-db": "0.1.37"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/archiver": "^7.0.0",
@@ -66,6 +66,11 @@ export function resolveCompanySkillsPath(companyId: string) {
66
66
  return join(resolveCompanyProjectsWorkspacePath(companyId), "skills");
67
67
  }
68
68
 
69
+ /** Company knowledge base (`knowledge/**`), markdown and text files on disk; exportable with company zip. */
70
+ export function resolveCompanyKnowledgePath(companyId: string) {
71
+ return join(resolveCompanyProjectsWorkspacePath(companyId), "knowledge");
72
+ }
73
+
69
74
  export function resolveAgentFallbackWorkspacePath(companyId: string, agentId: string) {
70
75
  const safeCompanyId = assertPathSegment(companyId, "companyId");
71
76
  const safeAgentId = assertPathSegment(agentId, "agentId");
@@ -118,7 +118,7 @@ export function createCompaniesRouter(ctx: AppContext) {
118
118
  return sendOk(res, {
119
119
  ok: false,
120
120
  companyName: "",
121
- counts: { projects: 0, agents: 0, goals: 0, routines: 0, skillFiles: 0 },
121
+ counts: { projects: 0, agents: 0, goals: 0, routines: 0, skillFiles: 0, knowledgeFiles: 0 },
122
122
  hasCeo: false,
123
123
  errors: [message],
124
124
  warnings: [] as string[]
@@ -50,6 +50,7 @@ import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePat
50
50
  import { requireCompanyScope } from "../middleware/company-scope";
51
51
  import { enforcePermission } from "../middleware/request-actor";
52
52
  import { triggerIssueCommentDispatchWorker } from "../services/comment-recipient-dispatch-service";
53
+ import { knowledgeFileExists } from "../services/company-knowledge-file-service";
53
54
  import { publishAttentionSnapshot } from "../realtime/attention";
54
55
  import {
55
56
  createIssueCommentLegacySchema,
@@ -108,10 +109,26 @@ function normalizeOptionalExternalLink(value: string | null | undefined) {
108
109
  return trimmed.length > 0 ? trimmed : null;
109
110
  }
110
111
 
112
+ async function validateIssueKnowledgePaths(companyId: string, paths: string[]): Promise<string | null> {
113
+ for (const p of paths) {
114
+ if (!(await knowledgeFileExists({ companyId, relativePath: p }))) {
115
+ return `Knowledge file not found: ${p}`;
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
111
121
  function toIssueResponse(issue: Record<string, unknown>, goalIds: string[] = []) {
112
122
  const labels = parseStringArray(issue.labelsJson);
113
123
  const tags = parseStringArray(issue.tagsJson);
114
- const { labelsJson: _labelsJson, tagsJson: _tagsJson, goalId: _legacyGoalId, ...rest } = issue as Record<string, unknown> & {
124
+ const knowledgePaths = parseStringArray(issue.knowledgePathsJson);
125
+ const {
126
+ labelsJson: _labelsJson,
127
+ tagsJson: _tagsJson,
128
+ knowledgePathsJson: _knowledgePathsJson,
129
+ goalId: _legacyGoalId,
130
+ ...rest
131
+ } = issue as Record<string, unknown> & {
115
132
  goalId?: unknown;
116
133
  };
117
134
  const externalRaw = rest.externalLink;
@@ -122,7 +139,8 @@ function toIssueResponse(issue: Record<string, unknown>, goalIds: string[] = [])
122
139
  externalLink,
123
140
  labels,
124
141
  tags,
125
- goalIds
142
+ goalIds,
143
+ knowledgePaths
126
144
  };
127
145
  }
128
146
 
@@ -209,6 +227,12 @@ export function createIssuesRouter(ctx: AppContext) {
209
227
  return sendError(res, assignmentValidation, 422);
210
228
  }
211
229
  }
230
+ if (parsed.data.knowledgePaths.length > 0) {
231
+ const kpErr = await validateIssueKnowledgePaths(req.companyId!, parsed.data.knowledgePaths);
232
+ if (kpErr) {
233
+ return sendError(res, kpErr, 422);
234
+ }
235
+ }
212
236
  const issue = await createIssue(ctx.db, {
213
237
  companyId: req.companyId!,
214
238
  projectId: parsed.data.projectId,
@@ -221,7 +245,8 @@ export function createIssuesRouter(ctx: AppContext) {
221
245
  priority: parsed.data.priority,
222
246
  assigneeAgentId: parsed.data.assigneeAgentId,
223
247
  labels: parsed.data.labels,
224
- tags: parsed.data.tags
248
+ tags: parsed.data.tags,
249
+ knowledgePaths: parsed.data.knowledgePaths
225
250
  });
226
251
  await appendActivity(ctx.db, {
227
252
  companyId: req.companyId!,
@@ -606,6 +631,12 @@ export function createIssuesRouter(ctx: AppContext) {
606
631
  if (updateBody.externalLink !== undefined) {
607
632
  updateBody.externalLink = normalizeOptionalExternalLink(updateBody.externalLink) ?? null;
608
633
  }
634
+ if (updateBody.knowledgePaths !== undefined && updateBody.knowledgePaths.length > 0) {
635
+ const kpErr = await validateIssueKnowledgePaths(req.companyId!, updateBody.knowledgePaths);
636
+ if (kpErr) {
637
+ return sendError(res, kpErr, 422);
638
+ }
639
+ }
609
640
  const issue = await updateIssue(ctx.db, { companyId: req.companyId!, id: req.params.issueId, ...updateBody });
610
641
  if (!issue) {
611
642
  return sendError(res, "Issue not found.", 404);
@@ -25,14 +25,27 @@ import {
25
25
  writeAgentOperatingFile
26
26
  } from "../services/agent-operating-file-service";
27
27
  import { BUILTIN_BOPO_SKILLS } from "../lib/builtin-bopo-skills";
28
+ import {
29
+ buildKnowledgeTreeFromPaths,
30
+ createKnowledgeFile,
31
+ deleteKnowledgeFile,
32
+ listKnowledgeFiles,
33
+ readKnowledgeFile,
34
+ renameKnowledgeFile,
35
+ renameKnowledgeFolderPrefix,
36
+ writeKnowledgeFile
37
+ } from "../services/company-knowledge-file-service";
28
38
  import {
29
39
  createCompanySkillPackage,
40
+ deleteCompanySkillFile,
30
41
  deleteCompanySkillPackage,
31
42
  linkCompanySkillFromUrl,
32
43
  listCompanySkillFiles,
33
44
  listCompanySkillPackages,
34
45
  refreshCompanySkillFromUrl,
35
46
  readCompanySkillFile,
47
+ renameCompanySkillFile,
48
+ setCompanySkillSidebarTitle,
36
49
  writeCompanySkillFile
37
50
  } from "../services/company-skill-file-service";
38
51
  import {
@@ -550,6 +563,7 @@ export function createObservabilityRouter(ctx: AppContext) {
550
563
  linkedUrl: pack.linkedUrl,
551
564
  linkLastFetchedAt: pack.linkLastFetchedAt,
552
565
  hasLocalSkillMd,
566
+ sidebarTitle: pack.sidebarTitle,
553
567
  files: relativePaths.map((relativePath) => ({ relativePath }))
554
568
  };
555
569
  })
@@ -602,6 +616,77 @@ export function createObservabilityRouter(ctx: AppContext) {
602
616
  }
603
617
  });
604
618
 
619
+ router.patch("/company-skills/file", async (req, res) => {
620
+ if (!enforcePermission(req, res, "agents:write")) {
621
+ return;
622
+ }
623
+ const companyId = req.companyId!;
624
+ const skillId = typeof req.query.skillId === "string" ? req.query.skillId.trim() : "";
625
+ if (!skillId) {
626
+ return sendError(res, "Query parameter 'skillId' is required.", 422);
627
+ }
628
+ const body = req.body as { from?: unknown; to?: unknown };
629
+ if (typeof body?.from !== "string" || !body.from.trim()) {
630
+ return sendError(res, "Expected JSON body with string 'from' (current path).", 422);
631
+ }
632
+ if (typeof body?.to !== "string" || !body.to.trim()) {
633
+ return sendError(res, "Expected JSON body with string 'to' (new path).", 422);
634
+ }
635
+ try {
636
+ const result = await renameCompanySkillFile({
637
+ companyId,
638
+ skillId,
639
+ fromRelativePath: body.from.trim(),
640
+ toRelativePath: body.to.trim()
641
+ });
642
+ return sendOk(res, result);
643
+ } catch (error) {
644
+ return sendError(res, String(error), 422);
645
+ }
646
+ });
647
+
648
+ router.patch("/company-skills/sidebar-title", async (req, res) => {
649
+ if (!enforcePermission(req, res, "agents:write")) {
650
+ return;
651
+ }
652
+ const companyId = req.companyId!;
653
+ const body = req.body as { skillId?: unknown; title?: unknown };
654
+ if (typeof body?.skillId !== "string" || !body.skillId.trim()) {
655
+ return sendError(res, "Expected JSON body with string 'skillId'.", 422);
656
+ }
657
+ if (typeof body?.title !== "string") {
658
+ return sendError(res, "Expected JSON body with string 'title' (empty string resets to skill id).", 422);
659
+ }
660
+ try {
661
+ const result = await setCompanySkillSidebarTitle({
662
+ companyId,
663
+ skillId: body.skillId.trim(),
664
+ title: body.title
665
+ });
666
+ return sendOk(res, result);
667
+ } catch (error) {
668
+ return sendError(res, String(error), 422);
669
+ }
670
+ });
671
+
672
+ router.delete("/company-skills/file", async (req, res) => {
673
+ if (!enforcePermission(req, res, "agents:write")) {
674
+ return;
675
+ }
676
+ const companyId = req.companyId!;
677
+ const skillId = typeof req.query.skillId === "string" ? req.query.skillId.trim() : "";
678
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
679
+ if (!skillId || !relativePath) {
680
+ return sendError(res, "Query parameters 'skillId' and 'path' are required.", 422);
681
+ }
682
+ try {
683
+ const result = await deleteCompanySkillFile({ companyId, skillId, relativePath });
684
+ return sendOk(res, result);
685
+ } catch (error) {
686
+ return sendError(res, String(error), 422);
687
+ }
688
+ });
689
+
605
690
  router.post("/company-skills/create", async (req, res) => {
606
691
  if (!enforcePermission(req, res, "agents:write")) {
607
692
  return;
@@ -679,6 +764,143 @@ export function createObservabilityRouter(ctx: AppContext) {
679
764
  }
680
765
  });
681
766
 
767
+ router.get("/company-knowledge", async (req, res) => {
768
+ const companyId = req.companyId!;
769
+ try {
770
+ const { files } = await listKnowledgeFiles({ companyId });
771
+ const tree = buildKnowledgeTreeFromPaths(files);
772
+ return sendOk(res, { items: files, tree });
773
+ } catch (error) {
774
+ return sendError(res, String(error), 422);
775
+ }
776
+ });
777
+
778
+ router.get("/company-knowledge/file", async (req, res) => {
779
+ const companyId = req.companyId!;
780
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
781
+ if (!relativePath) {
782
+ return sendError(res, "Query parameter 'path' is required.", 422);
783
+ }
784
+ try {
785
+ const file = await readKnowledgeFile({ companyId, relativePath });
786
+ return sendOk(res, { content: file.content });
787
+ } catch (error) {
788
+ return sendError(res, String(error), 422);
789
+ }
790
+ });
791
+
792
+ router.put("/company-knowledge/file", async (req, res) => {
793
+ if (!enforcePermission(req, res, "agents:write")) {
794
+ return;
795
+ }
796
+ const companyId = req.companyId!;
797
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
798
+ if (!relativePath) {
799
+ return sendError(res, "Query parameter 'path' is required.", 422);
800
+ }
801
+ const body = req.body as { content?: unknown };
802
+ if (typeof body?.content !== "string") {
803
+ return sendError(res, "Expected JSON body with string 'content'.", 422);
804
+ }
805
+ try {
806
+ const result = await writeKnowledgeFile({
807
+ companyId,
808
+ relativePath,
809
+ content: body.content
810
+ });
811
+ return sendOk(res, result);
812
+ } catch (error) {
813
+ return sendError(res, String(error), 422);
814
+ }
815
+ });
816
+
817
+ router.post("/company-knowledge/file", async (req, res) => {
818
+ if (!enforcePermission(req, res, "agents:write")) {
819
+ return;
820
+ }
821
+ const companyId = req.companyId!;
822
+ const body = req.body as { path?: unknown; content?: unknown };
823
+ if (typeof body?.path !== "string" || !body.path.trim()) {
824
+ return sendError(res, "Expected JSON body with string 'path'.", 422);
825
+ }
826
+ const content = typeof body.content === "string" ? body.content : undefined;
827
+ try {
828
+ const result = await createKnowledgeFile({
829
+ companyId,
830
+ relativePath: body.path.trim(),
831
+ ...(content !== undefined ? { content } : {})
832
+ });
833
+ return sendOk(res, result);
834
+ } catch (error) {
835
+ return sendError(res, String(error), 422);
836
+ }
837
+ });
838
+
839
+ router.patch("/company-knowledge/file", async (req, res) => {
840
+ if (!enforcePermission(req, res, "agents:write")) {
841
+ return;
842
+ }
843
+ const companyId = req.companyId!;
844
+ const body = req.body as { from?: unknown; to?: unknown };
845
+ if (typeof body?.from !== "string" || !body.from.trim()) {
846
+ return sendError(res, "Expected JSON body with string 'from' (current path).", 422);
847
+ }
848
+ if (typeof body?.to !== "string" || !body.to.trim()) {
849
+ return sendError(res, "Expected JSON body with string 'to' (new path).", 422);
850
+ }
851
+ try {
852
+ const result = await renameKnowledgeFile({
853
+ companyId,
854
+ fromRelativePath: body.from.trim(),
855
+ toRelativePath: body.to.trim()
856
+ });
857
+ return sendOk(res, result);
858
+ } catch (error) {
859
+ return sendError(res, String(error), 422);
860
+ }
861
+ });
862
+
863
+ router.patch("/company-knowledge/folder", async (req, res) => {
864
+ if (!enforcePermission(req, res, "agents:write")) {
865
+ return;
866
+ }
867
+ const companyId = req.companyId!;
868
+ const body = req.body as { from?: unknown; to?: unknown };
869
+ if (typeof body?.from !== "string" || !body.from.trim()) {
870
+ return sendError(res, "Expected JSON body with string 'from' (current folder prefix).", 422);
871
+ }
872
+ if (typeof body?.to !== "string" || !body.to.trim()) {
873
+ return sendError(res, "Expected JSON body with string 'to' (new folder prefix).", 422);
874
+ }
875
+ try {
876
+ const result = await renameKnowledgeFolderPrefix({
877
+ companyId,
878
+ fromPrefix: body.from.trim(),
879
+ toPrefix: body.to.trim()
880
+ });
881
+ return sendOk(res, result);
882
+ } catch (error) {
883
+ return sendError(res, String(error), 422);
884
+ }
885
+ });
886
+
887
+ router.delete("/company-knowledge/file", async (req, res) => {
888
+ if (!enforcePermission(req, res, "agents:write")) {
889
+ return;
890
+ }
891
+ const companyId = req.companyId!;
892
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
893
+ if (!relativePath) {
894
+ return sendError(res, "Query parameter 'path' is required.", 422);
895
+ }
896
+ try {
897
+ const result = await deleteKnowledgeFile({ companyId, relativePath });
898
+ return sendOk(res, result);
899
+ } catch (error) {
900
+ return sendError(res, String(error), 422);
901
+ }
902
+ });
903
+
682
904
  router.get("/memory/:agentId/context-preview", async (req, res) => {
683
905
  const companyId = req.companyId!;
684
906
  const agentId = req.params.agentId;
@@ -8,9 +8,10 @@ import { getCompany, listAgents, listGoals, listProjects } from "bopodev-db";
8
8
  import {
9
9
  resolveAgentMemoryRootPath,
10
10
  resolveAgentOperatingPath,
11
+ resolveCompanyKnowledgePath,
11
12
  resolveCompanyProjectsWorkspacePath
12
13
  } from "../lib/instance-paths";
13
- import { SKILL_LINK_BASENAME } from "./company-skill-file-service";
14
+ import { SKILL_LINK_BASENAME, SKILL_SIDEBAR_TITLE_BASENAME } from "./company-skill-file-service";
14
15
  import { listWorkLoopTriggers, listWorkLoops } from "./work-loop-service/work-loop-service";
15
16
 
16
17
  const EXPORT_SCHEMA = "bopo/company-export/v1";
@@ -69,7 +70,7 @@ async function walkTextFilesUnder(rootAbs: string, budget: { n: number }): Promi
69
70
  return;
70
71
  }
71
72
  const name = ent.name;
72
- if (name.startsWith(".") && name !== SKILL_LINK_BASENAME) {
73
+ if (name.startsWith(".") && name !== SKILL_LINK_BASENAME && name !== SKILL_SIDEBAR_TITLE_BASENAME) {
73
74
  continue;
74
75
  }
75
76
  const full = join(dir, name);
@@ -110,12 +111,23 @@ async function walkSkillsDir(companyId: string, budget: { n: number }): Promise<
110
111
  return out;
111
112
  }
112
113
 
114
+ async function walkKnowledgeDir(companyId: string, budget: { n: number }): Promise<Record<string, string>> {
115
+ const root = resolveCompanyKnowledgePath(companyId);
116
+ const files = await walkTextFilesUnder(root, budget);
117
+ const out: Record<string, string> = {};
118
+ for (const [rel, content] of Object.entries(files)) {
119
+ out[`knowledge/${rel}`] = content;
120
+ }
121
+ return out;
122
+ }
123
+
113
124
  function buildReadmeMarkdown(input: {
114
125
  companyName: string;
115
126
  slug: string;
116
127
  agentRows: { slug: string; name: string; role: string; managerSlug: string | null }[];
117
128
  projectRows: { slug: string; name: string; description: string | null }[];
118
129
  skillFileCount: number;
130
+ knowledgeFileCount: number;
119
131
  taskCount: number;
120
132
  goalCount: number;
121
133
  exportedAt: string;
@@ -131,6 +143,7 @@ function buildReadmeMarkdown(input: {
131
143
  `| Projects | ${input.projectRows.length} |`,
132
144
  `| Goals | ${input.goalCount} |`,
133
145
  `| Skills (files under skills/) | ${input.skillFileCount} |`,
146
+ `| Knowledge (files under knowledge/) | ${input.knowledgeFileCount} |`,
134
147
  `| Scheduled tasks | ${input.taskCount} |`,
135
148
  "",
136
149
  "### Agents",
@@ -350,6 +363,12 @@ export async function buildCompanyExportFileMap(
350
363
  files[p] = c;
351
364
  }
352
365
 
366
+ const knowledgeFiles = await walkKnowledgeDir(companyId, skillBudget);
367
+ const knowledgeFileCount = Object.keys(knowledgeFiles).length;
368
+ for (const [p, c] of Object.entries(knowledgeFiles)) {
369
+ files[p] = c;
370
+ }
371
+
353
372
  const taskCount = Object.keys(routineManifest).length;
354
373
 
355
374
  files["README.md"] = buildReadmeMarkdown({
@@ -358,6 +377,7 @@ export async function buildCompanyExportFileMap(
358
377
  agentRows: agentRowsForReadme,
359
378
  projectRows: projectEntries.map((p) => ({ slug: p.slug, name: p.name, description: p.description })),
360
379
  skillFileCount,
380
+ knowledgeFileCount,
361
381
  taskCount,
362
382
  goalCount: sortedGoals.length,
363
383
  exportedAt: yamlDoc.exportedAt
@@ -420,7 +440,9 @@ export async function listCompanyExportManifest(
420
440
  return Object.entries(files)
421
441
  .map(([path, content]): CompanyExportFileEntry => {
422
442
  const source: "generated" | "workspace" =
423
- path.startsWith("agents/") || path.startsWith("skills/") ? "workspace" : "generated";
443
+ path.startsWith("agents/") || path.startsWith("skills/") || path.startsWith("knowledge/")
444
+ ? "workspace"
445
+ : "generated";
424
446
  return {
425
447
  path,
426
448
  bytes: Buffer.byteLength(content, "utf8"),
@@ -328,6 +328,12 @@ export async function seedOperationalDataFromPackage(db: BopoDb, companyId: stri
328
328
  const dest = join(companyRoot, path);
329
329
  await mkdir(dirname(dest), { recursive: true });
330
330
  await writeFile(dest, text, "utf8");
331
+ continue;
332
+ }
333
+ if (path.startsWith("knowledge/")) {
334
+ const dest = join(companyRoot, path);
335
+ await mkdir(dirname(dest), { recursive: true });
336
+ await writeFile(dest, text, "utf8");
331
337
  }
332
338
  }
333
339
 
@@ -424,11 +430,15 @@ export function summarizeCompanyPackageForPreview(parsed: ParsedCompanyPackage):
424
430
  goals: number;
425
431
  routines: number;
426
432
  skillFiles: number;
433
+ knowledgeFiles: number;
427
434
  };
428
435
  hasCeo: boolean;
429
436
  } {
430
437
  const doc = parsed.doc;
431
438
  const skillFiles = Object.keys(parsed.entries).filter((k) => k.startsWith("skills/") && !k.endsWith("/")).length;
439
+ const knowledgeFiles = Object.keys(parsed.entries).filter(
440
+ (k) => k.startsWith("knowledge/") && !k.endsWith("/")
441
+ ).length;
432
442
  const hasCeo = Object.values(doc.agents).some((a) => (a.roleKey ?? "").trim().toLowerCase() === "ceo");
433
443
  return {
434
444
  companyName: doc.company.name,
@@ -437,7 +447,8 @@ export function summarizeCompanyPackageForPreview(parsed: ParsedCompanyPackage):
437
447
  agents: Object.keys(doc.agents).length,
438
448
  goals: Object.keys(doc.goals ?? {}).length,
439
449
  routines: Object.keys(doc.routines ?? {}).length,
440
- skillFiles
450
+ skillFiles,
451
+ knowledgeFiles
441
452
  },
442
453
  hasCeo
443
454
  };
@@ -0,0 +1,361 @@
1
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { isInsidePath, resolveCompanyKnowledgePath, resolveCompanyProjectsWorkspacePath } from "../lib/instance-paths";
4
+
5
+ const MAX_OBSERVABILITY_FILES = 200;
6
+ const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
7
+ const MAX_PATH_SEGMENTS = 32;
8
+ const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".txt", ".json"]);
9
+
10
+ /** Default file body when POST create omits `content`. Markdown/text start empty (no frontmatter boilerplate). */
11
+ function defaultContentForNewKnowledgeFile(relativePath: string): string {
12
+ const lower = relativePath.toLowerCase();
13
+ if (lower.endsWith(".json")) {
14
+ return "{}\n";
15
+ }
16
+ return "";
17
+ }
18
+
19
+ export function assertKnowledgeRelativePath(relativePath: string): string {
20
+ const normalized = relativePath.trim().replace(/\\/g, "/");
21
+ if (!normalized || normalized.startsWith("/") || normalized.includes("..")) {
22
+ throw new Error("Invalid relative path.");
23
+ }
24
+ const parts = normalized.split("/").filter(Boolean);
25
+ if (parts.length === 0 || parts.length > MAX_PATH_SEGMENTS) {
26
+ throw new Error("Invalid relative path.");
27
+ }
28
+ for (const p of parts) {
29
+ if (p === "." || p === ".." || p.startsWith(".")) {
30
+ throw new Error("Invalid relative path.");
31
+ }
32
+ }
33
+ const base = parts[parts.length - 1]!;
34
+ const lower = base.toLowerCase();
35
+ const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
36
+ if (!TEXT_EXT.has(ext)) {
37
+ throw new Error("Only text knowledge files (.md, .yaml, .yml, .txt, .json) are allowed.");
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ async function knowledgeRoot(companyId: string) {
43
+ const root = resolveCompanyKnowledgePath(companyId);
44
+ const companyWorkspace = resolveCompanyProjectsWorkspacePath(companyId);
45
+ if (!isInsidePath(companyWorkspace, root)) {
46
+ throw new Error("Invalid knowledge root.");
47
+ }
48
+ return { root };
49
+ }
50
+
51
+ export async function listKnowledgeFiles(input: { companyId: string; maxFiles?: number }) {
52
+ const { root } = await knowledgeRoot(input.companyId);
53
+ await mkdir(root, { recursive: true });
54
+ const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? MAX_OBSERVABILITY_FILES));
55
+ const relativePaths = await walkKnowledgeTextFiles(root, maxFiles);
56
+ return { root, files: relativePaths.map((relativePath) => ({ relativePath })) };
57
+ }
58
+
59
+ export type KnowledgeTreeNode =
60
+ | { type: "file"; name: string; relativePath: string }
61
+ | { type: "dir"; name: string; children: KnowledgeTreeNode[] };
62
+
63
+ /** Nested tree from flat relative paths (trie). */
64
+ export function buildKnowledgeTreeFromPaths(files: { relativePath: string }[]): KnowledgeTreeNode[] {
65
+ type Trie = { dirs: Map<string, Trie>; files: KnowledgeTreeNode[] };
66
+ const root: Trie = { dirs: new Map(), files: [] };
67
+
68
+ for (const { relativePath } of [...files].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
69
+ const parts = relativePath.split("/").filter(Boolean);
70
+ if (parts.length === 0) {
71
+ continue;
72
+ }
73
+ const fileName = parts.pop()!;
74
+ let node = root;
75
+ for (const segment of parts) {
76
+ if (!node.dirs.has(segment)) {
77
+ node.dirs.set(segment, { dirs: new Map(), files: [] });
78
+ }
79
+ node = node.dirs.get(segment)!;
80
+ }
81
+ node.files.push({ type: "file", name: fileName, relativePath });
82
+ }
83
+
84
+ function trieToNodes(trie: Trie): KnowledgeTreeNode[] {
85
+ const dirNodes: KnowledgeTreeNode[] = [];
86
+ for (const [name, child] of [...trie.dirs.entries()].sort(([a], [b]) => a.localeCompare(b))) {
87
+ const children = trieToNodes(child);
88
+ dirNodes.push({ type: "dir", name, children });
89
+ }
90
+ const sortedFiles = [...trie.files].sort((a, b) => a.name.localeCompare(b.name));
91
+ return [...dirNodes, ...sortedFiles];
92
+ }
93
+
94
+ return trieToNodes(root);
95
+ }
96
+
97
+ async function walkKnowledgeTextFiles(knowledgeDir: string, maxFiles: number): Promise<string[]> {
98
+ const collected: string[] = [];
99
+ const queue = [knowledgeDir];
100
+ while (queue.length > 0 && collected.length < maxFiles) {
101
+ const current = queue.shift();
102
+ if (!current) {
103
+ continue;
104
+ }
105
+ let entries: import("node:fs").Dirent[];
106
+ try {
107
+ entries = await readdir(current, { withFileTypes: true });
108
+ } catch {
109
+ continue;
110
+ }
111
+ for (const entry of entries) {
112
+ if (collected.length >= maxFiles) {
113
+ break;
114
+ }
115
+ const absolutePath = join(current, entry.name);
116
+ if (entry.isDirectory()) {
117
+ if (!entry.name.startsWith(".")) {
118
+ queue.push(absolutePath);
119
+ }
120
+ continue;
121
+ }
122
+ if (entry.name.startsWith(".")) {
123
+ continue;
124
+ }
125
+ const lower = entry.name.toLowerCase();
126
+ const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
127
+ if (TEXT_EXT.has(ext)) {
128
+ collected.push(relative(knowledgeDir, absolutePath).replace(/\\/g, "/"));
129
+ }
130
+ }
131
+ }
132
+ return collected.sort((a, b) => a.localeCompare(b));
133
+ }
134
+
135
+ export async function readKnowledgeFile(input: { companyId: string; relativePath: string }) {
136
+ const { root } = await knowledgeRoot(input.companyId);
137
+ const rel = assertKnowledgeRelativePath(input.relativePath);
138
+ const candidate = resolve(root, rel);
139
+ if (!isInsidePath(root, candidate)) {
140
+ throw new Error("Requested path is outside of knowledge directory.");
141
+ }
142
+ const info = await stat(candidate);
143
+ if (!info.isFile()) {
144
+ throw new Error("Requested path is not a file.");
145
+ }
146
+ if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
147
+ throw new Error("File exceeds size limit.");
148
+ }
149
+ const content = await readFile(candidate, "utf8");
150
+ return {
151
+ relativePath: rel,
152
+ content,
153
+ sizeBytes: info.size
154
+ };
155
+ }
156
+
157
+ export async function writeKnowledgeFile(input: {
158
+ companyId: string;
159
+ relativePath: string;
160
+ content: string;
161
+ }) {
162
+ const { root } = await knowledgeRoot(input.companyId);
163
+ const rel = assertKnowledgeRelativePath(input.relativePath);
164
+ const candidate = resolve(root, rel);
165
+ if (!isInsidePath(root, candidate)) {
166
+ throw new Error("Requested path is outside of knowledge directory.");
167
+ }
168
+ const bytes = Buffer.byteLength(input.content, "utf8");
169
+ if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
170
+ throw new Error("Content exceeds size limit.");
171
+ }
172
+ const parent = dirname(candidate);
173
+ if (!isInsidePath(root, parent)) {
174
+ throw new Error("Invalid parent directory.");
175
+ }
176
+ await mkdir(parent, { recursive: true });
177
+ await writeFile(candidate, input.content, { encoding: "utf8" });
178
+ const info = await stat(candidate);
179
+ return {
180
+ relativePath: rel,
181
+ sizeBytes: info.size
182
+ };
183
+ }
184
+
185
+ /** Create a new file; fails if it already exists. */
186
+ export async function createKnowledgeFile(input: {
187
+ companyId: string;
188
+ relativePath: string;
189
+ content?: string;
190
+ }) {
191
+ const { root } = await knowledgeRoot(input.companyId);
192
+ const rel = assertKnowledgeRelativePath(input.relativePath);
193
+ const candidate = resolve(root, rel);
194
+ if (!isInsidePath(root, candidate)) {
195
+ throw new Error("Requested path is outside of knowledge directory.");
196
+ }
197
+ try {
198
+ await stat(candidate);
199
+ throw new Error("A file already exists at this path.");
200
+ } catch (error) {
201
+ if (error instanceof Error && error.message === "A file already exists at this path.") {
202
+ throw error;
203
+ }
204
+ const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
205
+ if (code !== "ENOENT") {
206
+ throw error;
207
+ }
208
+ }
209
+ const body = input.content ?? defaultContentForNewKnowledgeFile(rel);
210
+ const bytes = Buffer.byteLength(body, "utf8");
211
+ if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
212
+ throw new Error("Content exceeds size limit.");
213
+ }
214
+ const parent = dirname(candidate);
215
+ if (!isInsidePath(root, parent)) {
216
+ throw new Error("Invalid parent directory.");
217
+ }
218
+ await mkdir(parent, { recursive: true });
219
+ await writeFile(candidate, body, { encoding: "utf8" });
220
+ const info = await stat(candidate);
221
+ return {
222
+ relativePath: rel,
223
+ sizeBytes: info.size
224
+ };
225
+ }
226
+
227
+ /** Rename/move a knowledge file within the knowledge root. */
228
+ export async function renameKnowledgeFile(input: {
229
+ companyId: string;
230
+ fromRelativePath: string;
231
+ toRelativePath: string;
232
+ }) {
233
+ const fromRel = assertKnowledgeRelativePath(input.fromRelativePath.trim());
234
+ const toRel = assertKnowledgeRelativePath(input.toRelativePath.trim());
235
+ if (fromRel === toRel) {
236
+ return { relativePath: toRel };
237
+ }
238
+ const { root } = await knowledgeRoot(input.companyId);
239
+ const fromAbs = resolve(root, fromRel);
240
+ const toAbs = resolve(root, toRel);
241
+ if (!isInsidePath(root, fromAbs) || !isInsidePath(root, toAbs)) {
242
+ throw new Error("Requested path is outside of knowledge directory.");
243
+ }
244
+ const fromInfo = await stat(fromAbs);
245
+ if (!fromInfo.isFile()) {
246
+ throw new Error("Source path is not a file.");
247
+ }
248
+ try {
249
+ await stat(toAbs);
250
+ throw new Error("A file already exists at the destination path.");
251
+ } catch (error) {
252
+ if (error instanceof Error && error.message === "A file already exists at the destination path.") {
253
+ throw error;
254
+ }
255
+ const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
256
+ if (code !== "ENOENT") {
257
+ throw error;
258
+ }
259
+ }
260
+ const parent = dirname(toAbs);
261
+ if (!isInsidePath(root, parent)) {
262
+ throw new Error("Invalid parent directory.");
263
+ }
264
+ await mkdir(parent, { recursive: true });
265
+ await rename(fromAbs, toAbs);
266
+ return { relativePath: toRel };
267
+ }
268
+
269
+ /** Folder path prefix (no trailing slash), e.g. `guides/onboarding`. */
270
+ export function assertKnowledgeFolderPrefix(prefix: string): string {
271
+ const normalized = prefix.trim().replace(/\\/g, "/").replace(/\/+$/g, "");
272
+ if (!normalized) {
273
+ throw new Error("Invalid folder path.");
274
+ }
275
+ if (normalized.startsWith("/") || normalized.includes("..")) {
276
+ throw new Error("Invalid folder path.");
277
+ }
278
+ const parts = normalized.split("/").filter(Boolean);
279
+ if (parts.length === 0 || parts.length > MAX_PATH_SEGMENTS - 1) {
280
+ throw new Error("Invalid folder path.");
281
+ }
282
+ for (const p of parts) {
283
+ if (p === "." || p === ".." || p.startsWith(".")) {
284
+ throw new Error("Invalid folder path.");
285
+ }
286
+ }
287
+ return parts.join("/");
288
+ }
289
+
290
+ /**
291
+ * Rename a knowledge folder by moving every file under `fromPrefix/` to the same relative paths under `toPrefix`.
292
+ */
293
+ export async function renameKnowledgeFolderPrefix(input: {
294
+ companyId: string;
295
+ fromPrefix: string;
296
+ toPrefix: string;
297
+ }) {
298
+ const fromP = assertKnowledgeFolderPrefix(input.fromPrefix);
299
+ const toP = assertKnowledgeFolderPrefix(input.toPrefix);
300
+ if (fromP === toP) {
301
+ return { moved: 0, fromPrefix: fromP, toPrefix: toP };
302
+ }
303
+ if (toP.startsWith(`${fromP}/`) || fromP.startsWith(`${toP}/`)) {
304
+ throw new Error("Invalid folder rename: one folder sits inside the other.");
305
+ }
306
+ const { files } = await listKnowledgeFiles({ companyId: input.companyId, maxFiles: MAX_OBSERVABILITY_FILES });
307
+ const paths = files.map((f) => f.relativePath);
308
+ const filePathsToMove = paths.filter((p) => p.startsWith(`${fromP}/`));
309
+ if (filePathsToMove.length === 0) {
310
+ throw new Error("No files found under that folder.");
311
+ }
312
+ const existing = new Set(paths);
313
+ for (const p of filePathsToMove) {
314
+ const np = `${toP}${p.slice(fromP.length)}`;
315
+ if (existing.has(np) && !filePathsToMove.includes(np)) {
316
+ throw new Error(`A file already exists at ${np}.`);
317
+ }
318
+ }
319
+ const sorted = [...filePathsToMove].sort((a, b) => b.length - a.length);
320
+ for (const p of sorted) {
321
+ const np = `${toP}${p.slice(fromP.length)}`;
322
+ await renameKnowledgeFile({
323
+ companyId: input.companyId,
324
+ fromRelativePath: p,
325
+ toRelativePath: np
326
+ });
327
+ existing.delete(p);
328
+ existing.add(np);
329
+ }
330
+ return { moved: sorted.length, fromPrefix: fromP, toPrefix: toP };
331
+ }
332
+
333
+ export async function deleteKnowledgeFile(input: { companyId: string; relativePath: string }) {
334
+ const { root } = await knowledgeRoot(input.companyId);
335
+ const rel = assertKnowledgeRelativePath(input.relativePath);
336
+ const candidate = resolve(root, rel);
337
+ if (!isInsidePath(root, candidate)) {
338
+ throw new Error("Requested path is outside of knowledge directory.");
339
+ }
340
+ const info = await stat(candidate);
341
+ if (!info.isFile()) {
342
+ throw new Error("Requested path is not a file.");
343
+ }
344
+ await rm(candidate, { force: true });
345
+ return { relativePath: rel };
346
+ }
347
+
348
+ export async function knowledgeFileExists(input: { companyId: string; relativePath: string }): Promise<boolean> {
349
+ try {
350
+ const rel = assertKnowledgeRelativePath(input.relativePath);
351
+ const { root } = await knowledgeRoot(input.companyId);
352
+ const candidate = resolve(root, rel);
353
+ if (!isInsidePath(root, candidate)) {
354
+ return false;
355
+ }
356
+ const info = await stat(candidate);
357
+ return info.isFile();
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, join, relative, resolve } from "node:path";
4
4
  import TurndownService from "turndown";
@@ -8,6 +8,9 @@ const MAX_OBSERVABILITY_FILES = 200;
8
8
  const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
9
9
  const SKILL_MD = "SKILL.md";
10
10
  export const SKILL_LINK_BASENAME = ".bopo-skill-link.json";
11
+ /** Optional UI-only label for the skill in Settings (does not rename files or the skill id). */
12
+ export const SKILL_SIDEBAR_TITLE_BASENAME = ".bopo-skill-sidebar-title.json";
13
+ const MAX_SKILL_SIDEBAR_TITLE_CHARS = 200;
11
14
  const SKILL_ID_RE = /^[a-zA-Z0-9_-]+$/;
12
15
  const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".txt", ".json"]);
13
16
 
@@ -27,6 +30,8 @@ export type CompanySkillPackageListItem = {
27
30
  skillId: string;
28
31
  linkedUrl: string | null;
29
32
  linkLastFetchedAt: string | null;
33
+ /** When set, shown in Settings sidebar instead of `skillId`. */
34
+ sidebarTitle: string | null;
30
35
  };
31
36
 
32
37
  export function assertCompanySkillId(skillId: string): string {
@@ -101,6 +106,24 @@ export async function readOptionalSkillLinkUrl(root: string): Promise<string | n
101
106
  return rec?.url ?? null;
102
107
  }
103
108
 
109
+ export async function readOptionalSkillSidebarTitle(root: string): Promise<string | null> {
110
+ try {
111
+ const raw = await readFile(join(root, SKILL_SIDEBAR_TITLE_BASENAME), "utf8");
112
+ const parsed: unknown = JSON.parse(raw);
113
+ if (!parsed || typeof parsed !== "object") {
114
+ return null;
115
+ }
116
+ const t = (parsed as { sidebarTitle?: unknown }).sidebarTitle;
117
+ if (typeof t !== "string") {
118
+ return null;
119
+ }
120
+ const s = t.trim();
121
+ return s.length > 0 ? s : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
104
127
  async function writeSkillLinkMetadata(root: string, url: string): Promise<{ lastFetchedAt: string }> {
105
128
  const lastFetchedAt = new Date().toISOString();
106
129
  await writeFile(
@@ -131,10 +154,12 @@ export async function listCompanySkillPackages(input: { companyId: string; maxSk
131
154
  if (!hasMd && !linkedUrl) {
132
155
  continue;
133
156
  }
157
+ const sidebarTitle = await readOptionalSkillSidebarTitle(skillDir);
134
158
  items.push({
135
159
  skillId: ent.name,
136
160
  linkedUrl,
137
- linkLastFetchedAt: linkRec?.lastFetchedAt ?? null
161
+ linkLastFetchedAt: linkRec?.lastFetchedAt ?? null,
162
+ sidebarTitle
138
163
  });
139
164
  if (items.length >= maxSkills) {
140
165
  break;
@@ -227,6 +252,47 @@ export async function readCompanySkillFile(input: {
227
252
  };
228
253
  }
229
254
 
255
+ function assertSkillSidebarTitle(trimmed: string): string {
256
+ if (!trimmed) {
257
+ throw new Error("Title is empty.");
258
+ }
259
+ if (trimmed.length > MAX_SKILL_SIDEBAR_TITLE_CHARS) {
260
+ throw new Error(`Title must be at most ${MAX_SKILL_SIDEBAR_TITLE_CHARS} characters.`);
261
+ }
262
+ if (/[\r\n]/.test(trimmed)) {
263
+ throw new Error("Title cannot contain line breaks.");
264
+ }
265
+ return trimmed;
266
+ }
267
+
268
+ /** Set or clear the Settings-only sidebar label for a company skill package. */
269
+ export async function setCompanySkillSidebarTitle(input: {
270
+ companyId: string;
271
+ skillId: string;
272
+ /** Empty / whitespace removes the custom title (sidebar falls back to skill id). */
273
+ title: string;
274
+ }) {
275
+ const { root, id } = await skillRoot(input.companyId, input.skillId);
276
+ const hasMd = await skillDirHasSkillMd(root);
277
+ const linkedUrl = await readOptionalSkillLinkUrl(root);
278
+ if (!hasMd && !linkedUrl) {
279
+ throw new Error("Skill not found.");
280
+ }
281
+ const metaPath = join(root, SKILL_SIDEBAR_TITLE_BASENAME);
282
+ const trimmedAll = input.title.trim();
283
+ if (!trimmedAll || trimmedAll === id) {
284
+ try {
285
+ await rm(metaPath, { force: true });
286
+ } catch {
287
+ /* noop */
288
+ }
289
+ return { skillId: id, sidebarTitle: null as string | null };
290
+ }
291
+ const t = assertSkillSidebarTitle(trimmedAll);
292
+ await writeFile(metaPath, JSON.stringify({ sidebarTitle: t }, null, 2), "utf8");
293
+ return { skillId: id, sidebarTitle: t };
294
+ }
295
+
230
296
  export async function writeCompanySkillFile(input: {
231
297
  companyId: string;
232
298
  skillId: string;
@@ -263,6 +329,89 @@ export async function writeCompanySkillFile(input: {
263
329
  };
264
330
  }
265
331
 
332
+ /** Rename/move a text file within one skill package directory. */
333
+ export async function renameCompanySkillFile(input: {
334
+ companyId: string;
335
+ skillId: string;
336
+ fromRelativePath: string;
337
+ toRelativePath: string;
338
+ }) {
339
+ const { root } = await skillRoot(input.companyId, input.skillId);
340
+ const fromRel = assertSkillRelativePath(input.fromRelativePath.trim());
341
+ const toRel = assertSkillRelativePath(input.toRelativePath.trim());
342
+ if (fromRel === toRel) {
343
+ return { skillId: input.skillId, relativePath: toRel };
344
+ }
345
+ const fromAbs = resolve(root, fromRel);
346
+ const toAbs = resolve(root, toRel);
347
+ if (!isInsidePath(root, fromAbs) || !isInsidePath(root, toAbs)) {
348
+ throw new Error("Requested path is outside of skill directory.");
349
+ }
350
+ const hasMd = await skillDirHasSkillMd(root);
351
+ const linkedUrl = await readOptionalSkillLinkUrl(root);
352
+ if (linkedUrl && !hasMd && fromRel === SKILL_MD) {
353
+ throw new Error("This skill is loaded from a URL only; save a local copy before renaming.");
354
+ }
355
+ const fromInfo = await stat(fromAbs);
356
+ if (!fromInfo.isFile()) {
357
+ throw new Error("Source path is not a file.");
358
+ }
359
+ try {
360
+ await stat(toAbs);
361
+ throw new Error("A file already exists at the destination path.");
362
+ } catch (error) {
363
+ if (error instanceof Error && error.message === "A file already exists at the destination path.") {
364
+ throw error;
365
+ }
366
+ const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
367
+ if (code !== "ENOENT") {
368
+ throw error;
369
+ }
370
+ }
371
+ const parent = dirname(toAbs);
372
+ if (!isInsidePath(root, parent)) {
373
+ throw new Error("Invalid parent directory.");
374
+ }
375
+ await mkdir(parent, { recursive: true });
376
+ await rename(fromAbs, toAbs);
377
+ return { skillId: input.skillId, relativePath: toRel };
378
+ }
379
+
380
+ /** Delete one text file from a skill package. Removing sole local SKILL.md with no URL link deletes the package folder. */
381
+ export async function deleteCompanySkillFile(input: {
382
+ companyId: string;
383
+ skillId: string;
384
+ relativePath: string;
385
+ }) {
386
+ const { root } = await skillRoot(input.companyId, input.skillId);
387
+ const rel = assertSkillRelativePath(input.relativePath.trim());
388
+ const candidate = resolve(root, rel);
389
+ if (!isInsidePath(root, candidate)) {
390
+ throw new Error("Requested path is outside of skill directory.");
391
+ }
392
+ const hasMd = await skillDirHasSkillMd(root);
393
+ const linkedUrl = await readOptionalSkillLinkUrl(root);
394
+ if (rel === SKILL_MD && linkedUrl && !hasMd) {
395
+ throw new Error("This skill is loaded from a URL only; delete the whole skill or refresh from URL.");
396
+ }
397
+ const allFiles = await walkSkillTextFiles(root, MAX_OBSERVABILITY_FILES);
398
+ if (rel === SKILL_MD && allFiles.length > 1) {
399
+ throw new Error("Remove other files in this skill before deleting SKILL.md.");
400
+ }
401
+ const info = await stat(candidate);
402
+ if (!info.isFile()) {
403
+ throw new Error("Requested path is not a file.");
404
+ }
405
+ await rm(candidate, { force: true });
406
+ if (rel === SKILL_MD && allFiles.length === 1) {
407
+ const stillLinked = await readOptionalSkillLinkUrl(root);
408
+ if (!stillLinked) {
409
+ await rm(root, { recursive: true, force: true });
410
+ }
411
+ }
412
+ return { skillId: input.skillId, relativePath: rel };
413
+ }
414
+
266
415
  /** Remove `skills/<id>/` entirely (local files and linked-skill pointer). */
267
416
  export async function deleteCompanySkillPackage(input: { companyId: string; skillId: string }) {
268
417
  const { root, id } = await skillRoot(input.companyId, input.skillId);
@@ -1,4 +1,19 @@
1
1
  import { z } from "zod";
2
+ import { assertKnowledgeRelativePath } from "../services/company-knowledge-file-service";
3
+
4
+ const KnowledgePathSchema = z.string().min(1).max(1024).superRefine((val, ctx) => {
5
+ try {
6
+ assertKnowledgeRelativePath(val);
7
+ } catch (err) {
8
+ ctx.addIssue({
9
+ code: z.ZodIssueCode.custom,
10
+ message: err instanceof Error ? err.message : "Invalid knowledge path"
11
+ });
12
+ }
13
+ });
14
+
15
+ const knowledgePathsCreate = z.array(KnowledgePathSchema).max(20).default([]);
16
+ const knowledgePathsUpdate = z.array(KnowledgePathSchema).max(20);
2
17
 
3
18
  export const createIssueSchema = z.object({
4
19
  projectId: z.string().min(1),
@@ -28,7 +43,8 @@ export const createIssueSchema = z.object({
28
43
  goalIds: z.array(z.string().min(1)).default([]),
29
44
  externalLink: z.string().max(2048).nullable().optional(),
30
45
  labels: z.array(z.string()).default([]),
31
- tags: z.array(z.string()).default([])
46
+ tags: z.array(z.string()).default([]),
47
+ knowledgePaths: knowledgePathsCreate
32
48
  });
33
49
 
34
50
  export const createIssueCommentSchema = z.object({
@@ -75,6 +91,7 @@ export const updateIssueSchema = z
75
91
  goalIds: z.array(z.string().min(1)).optional(),
76
92
  externalLink: z.string().max(2048).nullable().optional(),
77
93
  labels: z.array(z.string()).optional(),
78
- tags: z.array(z.string()).optional()
94
+ tags: z.array(z.string()).optional(),
95
+ knowledgePaths: knowledgePathsUpdate.optional()
79
96
  })
80
97
  .refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");