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 +4 -4
- package/src/lib/instance-paths.ts +5 -0
- package/src/routes/companies.ts +1 -1
- package/src/routes/issues.ts +34 -3
- package/src/routes/observability.ts +222 -0
- package/src/services/company-file-archive-service.ts +25 -3
- package/src/services/company-file-import-service.ts +12 -1
- package/src/services/company-knowledge-file-service.ts +361 -0
- package/src/services/company-skill-file-service.ts +151 -2
- package/src/validation/issue-routes.ts +19 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
25
|
-
"bopodev-
|
|
26
|
-
"bopodev-
|
|
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");
|
package/src/routes/companies.ts
CHANGED
|
@@ -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[]
|
package/src/routes/issues.ts
CHANGED
|
@@ -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
|
|
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/")
|
|
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.");
|