bopodev-api 0.1.29 → 0.1.31
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/app.ts +2 -0
- package/src/lib/ceo-bootstrap-prompt.ts +1 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/realtime/office-space.ts +1 -0
- package/src/routes/agents.ts +87 -37
- package/src/routes/companies.ts +2 -0
- package/src/routes/issues.ts +3 -0
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +123 -1
- package/src/scripts/onboard-seed.ts +13 -1
- package/src/services/agent-operating-file-service.ts +116 -0
- package/src/services/governance-service.ts +6 -13
- package/src/services/heartbeat-service/heartbeat-run.ts +25 -3
- package/src/services/heartbeat-service/types.ts +1 -0
- package/src/services/memory-file-service.ts +35 -1
- package/src/services/template-apply-service.ts +39 -0
- package/src/services/template-catalog.ts +37 -3
- package/src/services/work-loop-service/index.ts +2 -0
- package/src/services/work-loop-service/loop-cron.ts +197 -0
- package/src/services/work-loop-service/work-loop-service.ts +665 -0
- package/src/validation/issue-routes.ts +2 -1
- package/src/worker/scheduler.ts +26 -1
|
@@ -16,7 +16,18 @@ import type { AppContext } from "../context";
|
|
|
16
16
|
import { sendError, sendOk } from "../http";
|
|
17
17
|
import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
|
|
18
18
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
19
|
-
import {
|
|
19
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
20
|
+
import {
|
|
21
|
+
listAgentOperatingMarkdownFiles,
|
|
22
|
+
readAgentOperatingFile,
|
|
23
|
+
writeAgentOperatingFile
|
|
24
|
+
} from "../services/agent-operating-file-service";
|
|
25
|
+
import {
|
|
26
|
+
listAgentMemoryFiles,
|
|
27
|
+
loadAgentMemoryContext,
|
|
28
|
+
readAgentMemoryFile,
|
|
29
|
+
writeAgentMemoryFile
|
|
30
|
+
} from "../services/memory-file-service";
|
|
20
31
|
|
|
21
32
|
export function createObservabilityRouter(ctx: AppContext) {
|
|
22
33
|
const router = Router();
|
|
@@ -259,6 +270,117 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
259
270
|
}
|
|
260
271
|
});
|
|
261
272
|
|
|
273
|
+
router.put("/memory/:agentId/file", async (req, res) => {
|
|
274
|
+
if (!enforcePermission(req, res, "agents:write")) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const companyId = req.companyId!;
|
|
278
|
+
const agentId = req.params.agentId;
|
|
279
|
+
const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
|
|
280
|
+
if (!relativePath) {
|
|
281
|
+
return sendError(res, "Query parameter 'path' is required.", 422);
|
|
282
|
+
}
|
|
283
|
+
const body = req.body as { content?: unknown };
|
|
284
|
+
if (typeof body?.content !== "string") {
|
|
285
|
+
return sendError(res, "Expected JSON body with string 'content'.", 422);
|
|
286
|
+
}
|
|
287
|
+
const agents = await listAgents(ctx.db, companyId);
|
|
288
|
+
if (!agents.some((entry) => entry.id === agentId)) {
|
|
289
|
+
return sendError(res, "Agent not found", 404);
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const result = await writeAgentMemoryFile({
|
|
293
|
+
companyId,
|
|
294
|
+
agentId,
|
|
295
|
+
relativePath,
|
|
296
|
+
content: body.content
|
|
297
|
+
});
|
|
298
|
+
return sendOk(res, result);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return sendError(res, String(error), 422);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
router.get("/agent-operating/:agentId/files", async (req, res) => {
|
|
305
|
+
const companyId = req.companyId!;
|
|
306
|
+
const agentId = req.params.agentId;
|
|
307
|
+
const agents = await listAgents(ctx.db, companyId);
|
|
308
|
+
if (!agents.some((entry) => entry.id === agentId)) {
|
|
309
|
+
return sendError(res, "Agent not found", 404);
|
|
310
|
+
}
|
|
311
|
+
const rawLimit = Number(req.query.limit ?? 100);
|
|
312
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
|
|
313
|
+
try {
|
|
314
|
+
const files = await listAgentOperatingMarkdownFiles({
|
|
315
|
+
companyId,
|
|
316
|
+
agentId,
|
|
317
|
+
maxFiles: limit
|
|
318
|
+
});
|
|
319
|
+
return sendOk(res, {
|
|
320
|
+
items: files.map((file) => ({
|
|
321
|
+
relativePath: file.relativePath,
|
|
322
|
+
path: file.path
|
|
323
|
+
}))
|
|
324
|
+
});
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return sendError(res, String(error), 422);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
router.get("/agent-operating/:agentId/file", async (req, res) => {
|
|
331
|
+
const companyId = req.companyId!;
|
|
332
|
+
const agentId = req.params.agentId;
|
|
333
|
+
const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
|
|
334
|
+
if (!relativePath) {
|
|
335
|
+
return sendError(res, "Query parameter 'path' is required.", 422);
|
|
336
|
+
}
|
|
337
|
+
const agents = await listAgents(ctx.db, companyId);
|
|
338
|
+
if (!agents.some((entry) => entry.id === agentId)) {
|
|
339
|
+
return sendError(res, "Agent not found", 404);
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const file = await readAgentOperatingFile({
|
|
343
|
+
companyId,
|
|
344
|
+
agentId,
|
|
345
|
+
relativePath
|
|
346
|
+
});
|
|
347
|
+
return sendOk(res, file);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return sendError(res, String(error), 422);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
router.put("/agent-operating/:agentId/file", async (req, res) => {
|
|
354
|
+
if (!enforcePermission(req, res, "agents:write")) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const companyId = req.companyId!;
|
|
358
|
+
const agentId = req.params.agentId;
|
|
359
|
+
const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
|
|
360
|
+
if (!relativePath) {
|
|
361
|
+
return sendError(res, "Query parameter 'path' is required.", 422);
|
|
362
|
+
}
|
|
363
|
+
const body = req.body as { content?: unknown };
|
|
364
|
+
if (typeof body?.content !== "string") {
|
|
365
|
+
return sendError(res, "Expected JSON body with string 'content'.", 422);
|
|
366
|
+
}
|
|
367
|
+
const agents = await listAgents(ctx.db, companyId);
|
|
368
|
+
if (!agents.some((entry) => entry.id === agentId)) {
|
|
369
|
+
return sendError(res, "Agent not found", 404);
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const result = await writeAgentOperatingFile({
|
|
373
|
+
companyId,
|
|
374
|
+
agentId,
|
|
375
|
+
relativePath,
|
|
376
|
+
content: body.content
|
|
377
|
+
});
|
|
378
|
+
return sendOk(res, result);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return sendError(res, String(error), 422);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
262
384
|
router.get("/memory/:agentId/context-preview", async (req, res) => {
|
|
263
385
|
const companyId = req.companyId!;
|
|
264
386
|
const agentId = req.params.agentId;
|
|
@@ -50,7 +50,16 @@ const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
|
|
|
50
50
|
const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
|
|
51
51
|
const DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
|
|
52
52
|
const DEFAULT_TEMPLATE_ENV = "BOPO_DEFAULT_TEMPLATE_ID";
|
|
53
|
-
type AgentProvider =
|
|
53
|
+
type AgentProvider =
|
|
54
|
+
| "codex"
|
|
55
|
+
| "claude_code"
|
|
56
|
+
| "cursor"
|
|
57
|
+
| "gemini_cli"
|
|
58
|
+
| "opencode"
|
|
59
|
+
| "openai_api"
|
|
60
|
+
| "anthropic_api"
|
|
61
|
+
| "openclaw_gateway"
|
|
62
|
+
| "shell";
|
|
54
63
|
const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
|
|
55
64
|
const STARTUP_PROJECT_NAME = "Leadership Setup";
|
|
56
65
|
const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
|
|
@@ -130,6 +139,8 @@ export async function ensureOnboardingSeed(input: {
|
|
|
130
139
|
role: "CEO",
|
|
131
140
|
roleKey: "ceo",
|
|
132
141
|
title: "CEO",
|
|
142
|
+
capabilities:
|
|
143
|
+
"Company leadership: priorities, hiring, governance, and aligning agents to mission and budget.",
|
|
133
144
|
name: "CEO",
|
|
134
145
|
providerType: agentProvider,
|
|
135
146
|
heartbeatCron: "*/5 * * * *",
|
|
@@ -399,6 +410,7 @@ function parseAgentProvider(value: unknown): AgentProvider | null {
|
|
|
399
410
|
value === "opencode" ||
|
|
400
411
|
value === "openai_api" ||
|
|
401
412
|
value === "anthropic_api" ||
|
|
413
|
+
value === "openclaw_gateway" ||
|
|
402
414
|
value === "shell"
|
|
403
415
|
) {
|
|
404
416
|
return value;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { isInsidePath, resolveAgentOperatingPath } from "../lib/instance-paths";
|
|
4
|
+
|
|
5
|
+
const MAX_OBSERVABILITY_FILES = 200;
|
|
6
|
+
const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
|
|
7
|
+
|
|
8
|
+
function isMarkdownFileName(name: string) {
|
|
9
|
+
return name.toLowerCase().endsWith(".md");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function walkMarkdownFiles(root: string, maxFiles: number) {
|
|
13
|
+
const collected: string[] = [];
|
|
14
|
+
const queue = [root];
|
|
15
|
+
while (queue.length > 0 && collected.length < maxFiles) {
|
|
16
|
+
const current = queue.shift();
|
|
17
|
+
if (!current) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const absolutePath = join(current, entry.name);
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
queue.push(absolutePath);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (entry.isFile() && isMarkdownFileName(entry.name)) {
|
|
28
|
+
collected.push(absolutePath);
|
|
29
|
+
if (collected.length >= maxFiles) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return collected.sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function listAgentOperatingMarkdownFiles(input: {
|
|
39
|
+
companyId: string;
|
|
40
|
+
agentId: string;
|
|
41
|
+
maxFiles?: number;
|
|
42
|
+
}) {
|
|
43
|
+
const root = resolveAgentOperatingPath(input.companyId, input.agentId);
|
|
44
|
+
await mkdir(root, { recursive: true });
|
|
45
|
+
const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? 100));
|
|
46
|
+
const files = await walkMarkdownFiles(root, maxFiles);
|
|
47
|
+
return files.map((filePath) => ({
|
|
48
|
+
path: filePath,
|
|
49
|
+
relativePath: relative(root, filePath),
|
|
50
|
+
operatingRoot: root
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function readAgentOperatingFile(input: {
|
|
55
|
+
companyId: string;
|
|
56
|
+
agentId: string;
|
|
57
|
+
relativePath: string;
|
|
58
|
+
}) {
|
|
59
|
+
const root = resolveAgentOperatingPath(input.companyId, input.agentId);
|
|
60
|
+
await mkdir(root, { recursive: true });
|
|
61
|
+
const candidate = resolve(root, input.relativePath);
|
|
62
|
+
if (!isInsidePath(root, candidate)) {
|
|
63
|
+
throw new Error("Requested operating path is outside of operating root.");
|
|
64
|
+
}
|
|
65
|
+
const info = await stat(candidate);
|
|
66
|
+
if (!info.isFile()) {
|
|
67
|
+
throw new Error("Requested operating path is not a file.");
|
|
68
|
+
}
|
|
69
|
+
if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
70
|
+
throw new Error("Requested operating file exceeds size limit.");
|
|
71
|
+
}
|
|
72
|
+
const content = await readFile(candidate, "utf8");
|
|
73
|
+
return {
|
|
74
|
+
path: candidate,
|
|
75
|
+
relativePath: relative(root, candidate),
|
|
76
|
+
content,
|
|
77
|
+
sizeBytes: info.size
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function writeAgentOperatingFile(input: {
|
|
82
|
+
companyId: string;
|
|
83
|
+
agentId: string;
|
|
84
|
+
relativePath: string;
|
|
85
|
+
content: string;
|
|
86
|
+
}) {
|
|
87
|
+
const root = resolveAgentOperatingPath(input.companyId, input.agentId);
|
|
88
|
+
await mkdir(root, { recursive: true });
|
|
89
|
+
const normalizedRel = input.relativePath.trim();
|
|
90
|
+
if (!normalizedRel || normalizedRel.includes("..")) {
|
|
91
|
+
throw new Error("Invalid relative path.");
|
|
92
|
+
}
|
|
93
|
+
if (!isMarkdownFileName(normalizedRel)) {
|
|
94
|
+
throw new Error("Only .md files can be written under the operating directory.");
|
|
95
|
+
}
|
|
96
|
+
const candidate = resolve(root, normalizedRel);
|
|
97
|
+
if (!isInsidePath(root, candidate)) {
|
|
98
|
+
throw new Error("Requested operating path is outside of operating root.");
|
|
99
|
+
}
|
|
100
|
+
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
101
|
+
if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
102
|
+
throw new Error("Content exceeds size limit.");
|
|
103
|
+
}
|
|
104
|
+
const parent = dirname(candidate);
|
|
105
|
+
if (!isInsidePath(root, parent)) {
|
|
106
|
+
throw new Error("Invalid parent directory.");
|
|
107
|
+
}
|
|
108
|
+
await mkdir(parent, { recursive: true });
|
|
109
|
+
await writeFile(candidate, input.content, { encoding: "utf8" });
|
|
110
|
+
const info = await stat(candidate);
|
|
111
|
+
return {
|
|
112
|
+
path: candidate,
|
|
113
|
+
relativePath: relative(root, candidate),
|
|
114
|
+
sizeBytes: info.size
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -60,19 +60,6 @@ const approvalGatedActions = new Set([
|
|
|
60
60
|
const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
|
|
61
61
|
sourceIssueId: z.string().min(1).optional(),
|
|
62
62
|
sourceIssueIds: z.array(z.string().min(1)).default([]),
|
|
63
|
-
delegationIntent: z
|
|
64
|
-
.object({
|
|
65
|
-
intentType: z.literal("agent_hiring_request"),
|
|
66
|
-
requestedRole: z.string().nullable().optional(),
|
|
67
|
-
requestedName: z.string().nullable().optional(),
|
|
68
|
-
requestedManagerAgentId: z.string().nullable().optional(),
|
|
69
|
-
requestedProviderType: z
|
|
70
|
-
.enum(["claude_code", "codex", "cursor", "opencode", "gemini_cli", "openai_api", "anthropic_api", "http", "shell"])
|
|
71
|
-
.nullable()
|
|
72
|
-
.optional(),
|
|
73
|
-
requestedRuntimeModel: z.string().nullable().optional()
|
|
74
|
-
})
|
|
75
|
-
.optional(),
|
|
76
63
|
runtimeCommand: z.string().optional(),
|
|
77
64
|
runtimeArgs: z.array(z.string()).optional(),
|
|
78
65
|
runtimeCwd: z.string().optional(),
|
|
@@ -310,6 +297,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
310
297
|
role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
|
|
311
298
|
roleKey: normalizeRoleKey(parsed.data.roleKey),
|
|
312
299
|
title: normalizeTitle(parsed.data.title),
|
|
300
|
+
capabilities: normalizeCapabilities(parsed.data.capabilities),
|
|
313
301
|
name: parsed.data.name,
|
|
314
302
|
providerType: parsed.data.providerType,
|
|
315
303
|
heartbeatCron: parsed.data.heartbeatCron,
|
|
@@ -739,6 +727,11 @@ function normalizeTitle(input: string | null | undefined) {
|
|
|
739
727
|
return normalized ? normalized : null;
|
|
740
728
|
}
|
|
741
729
|
|
|
730
|
+
function normalizeCapabilities(input: string | null | undefined) {
|
|
731
|
+
const normalized = input?.trim();
|
|
732
|
+
return normalized ? normalized : null;
|
|
733
|
+
}
|
|
734
|
+
|
|
742
735
|
function resolveAgentRoleText(
|
|
743
736
|
legacyRole: string | undefined,
|
|
744
737
|
roleKeyInput: string | undefined,
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
projects,
|
|
39
39
|
sql
|
|
40
40
|
} from "bopodev-db";
|
|
41
|
-
import { appendAuditEvent, appendCost } from "bopodev-db";
|
|
41
|
+
import { appendAuditEvent, appendCost, listAgents } from "bopodev-db";
|
|
42
42
|
import { parseRuntimeConfigFromAgentRow } from "../../lib/agent-config";
|
|
43
43
|
import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../../lib/git-runtime";
|
|
44
44
|
import {
|
|
@@ -669,7 +669,9 @@ export async function runHeartbeatForAgent(
|
|
|
669
669
|
},
|
|
670
670
|
failClosed: false
|
|
671
671
|
});
|
|
672
|
-
const isCommentOrderWake =
|
|
672
|
+
const isCommentOrderWake =
|
|
673
|
+
options?.wakeContext?.reason === "issue_comment_recipient" ||
|
|
674
|
+
options?.wakeContext?.reason === "loop_execution";
|
|
673
675
|
const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
|
|
674
676
|
const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
|
|
675
677
|
const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
|
|
@@ -1941,7 +1943,10 @@ function resolveExecutionWorkItems(
|
|
|
1941
1943
|
wakeContextItems: IssueWorkItemRow[],
|
|
1942
1944
|
wakeContext?: HeartbeatWakeContext
|
|
1943
1945
|
) {
|
|
1944
|
-
if (
|
|
1946
|
+
if (
|
|
1947
|
+
(wakeContext?.reason === "issue_comment_recipient" || wakeContext?.reason === "loop_execution") &&
|
|
1948
|
+
wakeContextItems.length > 0
|
|
1949
|
+
) {
|
|
1945
1950
|
return wakeContextItems;
|
|
1946
1951
|
}
|
|
1947
1952
|
return mergeContextWorkItems(assigned, wakeContextItems);
|
|
@@ -2182,6 +2187,22 @@ async function buildHeartbeatContext(
|
|
|
2182
2187
|
const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
|
|
2183
2188
|
const promptMode = resolveHeartbeatPromptMode();
|
|
2184
2189
|
|
|
2190
|
+
const companyAgentRows = await listAgents(db, companyId);
|
|
2191
|
+
const teamRoster = companyAgentRows
|
|
2192
|
+
.filter((row) => row.status !== "terminated")
|
|
2193
|
+
.sort((a, b) => {
|
|
2194
|
+
const byName = a.name.localeCompare(b.name);
|
|
2195
|
+
return byName !== 0 ? byName : a.id.localeCompare(b.id);
|
|
2196
|
+
})
|
|
2197
|
+
.map((row) => ({
|
|
2198
|
+
id: row.id,
|
|
2199
|
+
name: row.name,
|
|
2200
|
+
role: row.role,
|
|
2201
|
+
title: row.title ?? null,
|
|
2202
|
+
capabilities: row.capabilities ?? null,
|
|
2203
|
+
status: row.status
|
|
2204
|
+
}));
|
|
2205
|
+
|
|
2185
2206
|
return {
|
|
2186
2207
|
companyId,
|
|
2187
2208
|
agentId: input.agentId,
|
|
@@ -2197,6 +2218,7 @@ async function buildHeartbeatContext(
|
|
|
2197
2218
|
role: input.agentRole,
|
|
2198
2219
|
managerAgentId: input.managerAgentId
|
|
2199
2220
|
},
|
|
2221
|
+
teamRoster,
|
|
2200
2222
|
state: input.state,
|
|
2201
2223
|
memoryContext: input.memoryContext,
|
|
2202
2224
|
runtime: input.runtime,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join, relative, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import type { AgentMemoryContext } from "bopodev-agent-sdk";
|
|
4
4
|
import {
|
|
5
5
|
isInsidePath,
|
|
@@ -227,6 +227,40 @@ export async function readAgentMemoryFile(input: {
|
|
|
227
227
|
};
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
export async function writeAgentMemoryFile(input: {
|
|
231
|
+
companyId: string;
|
|
232
|
+
agentId: string;
|
|
233
|
+
relativePath: string;
|
|
234
|
+
content: string;
|
|
235
|
+
}) {
|
|
236
|
+
const root = resolveAgentMemoryRootPath(input.companyId, input.agentId);
|
|
237
|
+
await mkdir(root, { recursive: true });
|
|
238
|
+
const normalizedRel = input.relativePath.trim();
|
|
239
|
+
if (!normalizedRel || normalizedRel.includes("..")) {
|
|
240
|
+
throw new Error("Invalid relative path.");
|
|
241
|
+
}
|
|
242
|
+
const candidate = resolve(root, normalizedRel);
|
|
243
|
+
if (!isInsidePath(root, candidate)) {
|
|
244
|
+
throw new Error("Requested memory path is outside of memory root.");
|
|
245
|
+
}
|
|
246
|
+
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
247
|
+
if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
248
|
+
throw new Error("Content exceeds size limit.");
|
|
249
|
+
}
|
|
250
|
+
const parent = dirname(candidate);
|
|
251
|
+
if (!isInsidePath(root, parent)) {
|
|
252
|
+
throw new Error("Invalid parent directory.");
|
|
253
|
+
}
|
|
254
|
+
await mkdir(parent, { recursive: true });
|
|
255
|
+
await writeFile(candidate, input.content, { encoding: "utf8" });
|
|
256
|
+
const info = await stat(candidate);
|
|
257
|
+
return {
|
|
258
|
+
path: candidate,
|
|
259
|
+
relativePath: relative(root, candidate),
|
|
260
|
+
sizeBytes: info.size
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
230
264
|
function collapseWhitespace(value: string) {
|
|
231
265
|
return value.replace(/\s+/g, " ").trim();
|
|
232
266
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
updatePluginConfig
|
|
10
10
|
} from "bopodev-db";
|
|
11
11
|
import { interpolateTemplateManifest, buildTemplatePreview } from "./template-preview-service";
|
|
12
|
+
import { addWorkLoopTrigger, createWorkLoop } from "./work-loop-service";
|
|
12
13
|
|
|
13
14
|
export class TemplateApplyError extends Error {
|
|
14
15
|
constructor(message: string) {
|
|
@@ -62,6 +63,7 @@ export async function applyTemplateManifest(
|
|
|
62
63
|
role: resolveAgentRoleText(agent.role, agent.roleKey, agent.title),
|
|
63
64
|
roleKey: normalizeRoleKey(agent.roleKey),
|
|
64
65
|
title: normalizeTitle(agent.title),
|
|
66
|
+
capabilities: normalizeCapabilities(agent.capabilities),
|
|
65
67
|
name: agent.name,
|
|
66
68
|
providerType: agent.providerType,
|
|
67
69
|
heartbeatCron: agent.heartbeatCron,
|
|
@@ -122,6 +124,38 @@ export async function applyTemplateManifest(
|
|
|
122
124
|
});
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
const firstProjectId =
|
|
128
|
+
renderedManifest.projects.length > 0
|
|
129
|
+
? projectIdByKey.get(renderedManifest.projects[0]!.key) ?? null
|
|
130
|
+
: Array.from(projectIdByKey.values())[0] ?? null;
|
|
131
|
+
for (const job of renderedManifest.recurrence) {
|
|
132
|
+
if (job.targetType !== "agent") {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const assigneeAgentId = agentIdByKey.get(job.targetKey) ?? null;
|
|
136
|
+
if (!assigneeAgentId || !firstProjectId) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const title =
|
|
140
|
+
job.instruction?.trim() && job.instruction.trim().length > 0
|
|
141
|
+
? job.instruction.trim()
|
|
142
|
+
: `Recurring work: ${job.targetKey}`;
|
|
143
|
+
const loop = await createWorkLoop(db, {
|
|
144
|
+
companyId: input.companyId,
|
|
145
|
+
projectId: firstProjectId,
|
|
146
|
+
title,
|
|
147
|
+
description: job.instruction?.trim() || null,
|
|
148
|
+
assigneeAgentId
|
|
149
|
+
});
|
|
150
|
+
if (loop) {
|
|
151
|
+
await addWorkLoopTrigger(db, {
|
|
152
|
+
companyId: input.companyId,
|
|
153
|
+
workLoopId: loop.id,
|
|
154
|
+
cronExpression: job.cron
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
125
159
|
const install = await createTemplateInstall(db, {
|
|
126
160
|
companyId: input.companyId,
|
|
127
161
|
templateId: input.templateId,
|
|
@@ -152,6 +186,11 @@ function normalizeTitle(input: string | null | undefined) {
|
|
|
152
186
|
return normalized ? normalized : null;
|
|
153
187
|
}
|
|
154
188
|
|
|
189
|
+
function normalizeCapabilities(input: string | null | undefined) {
|
|
190
|
+
const normalized = input?.trim();
|
|
191
|
+
return normalized ? normalized : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
155
194
|
function resolveAgentRoleText(
|
|
156
195
|
legacyRole: string | undefined,
|
|
157
196
|
roleKeyInput: string | undefined,
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
createTemplate,
|
|
10
10
|
createTemplateVersion,
|
|
11
11
|
getTemplateBySlug,
|
|
12
|
-
getTemplateVersionByVersion
|
|
12
|
+
getTemplateVersionByVersion,
|
|
13
|
+
updateTemplate
|
|
13
14
|
} from "bopodev-db";
|
|
14
15
|
|
|
15
16
|
type BuiltinTemplateDefinition = {
|
|
@@ -28,7 +29,7 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
28
29
|
slug: "founder-startup-basic",
|
|
29
30
|
name: "Founder Startup Basic",
|
|
30
31
|
description: "Baseline operating company for solo founders launching and shipping with AI agents.",
|
|
31
|
-
version: "1.0.
|
|
32
|
+
version: "1.0.1",
|
|
32
33
|
status: "published",
|
|
33
34
|
visibility: "company",
|
|
34
35
|
variables: [
|
|
@@ -78,6 +79,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
78
79
|
{
|
|
79
80
|
key: "founder-ceo",
|
|
80
81
|
role: "CEO",
|
|
82
|
+
roleKey: "ceo",
|
|
83
|
+
title: "Founder CEO",
|
|
84
|
+
capabilities:
|
|
85
|
+
"Sets company priorities, runs leadership cadence, hires and coordinates agents toward mission outcomes.",
|
|
81
86
|
name: "Founder CEO",
|
|
82
87
|
providerType: "codex",
|
|
83
88
|
heartbeatCron: "*/15 * * * *",
|
|
@@ -119,6 +124,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
119
124
|
{
|
|
120
125
|
key: "founding-engineer",
|
|
121
126
|
role: "Founding Engineer",
|
|
127
|
+
roleKey: "engineer",
|
|
128
|
+
title: "Founding Engineer",
|
|
129
|
+
capabilities:
|
|
130
|
+
"Ships product improvements with small reviewable changes, tests, and clear handoffs to stakeholders.",
|
|
122
131
|
name: "Founding Engineer",
|
|
123
132
|
managerAgentKey: "founder-ceo",
|
|
124
133
|
providerType: "codex",
|
|
@@ -152,6 +161,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
152
161
|
{
|
|
153
162
|
key: "growth-operator",
|
|
154
163
|
role: "Growth Operator",
|
|
164
|
+
roleKey: "general",
|
|
165
|
+
title: "Growth Operator",
|
|
166
|
+
capabilities:
|
|
167
|
+
"Runs growth experiments, measures funnel impact, and feeds learnings back to leadership with clear next steps.",
|
|
155
168
|
name: "Growth Operator",
|
|
156
169
|
managerAgentKey: "founder-ceo",
|
|
157
170
|
providerType: "codex",
|
|
@@ -225,7 +238,7 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
225
238
|
slug: "marketing-content-engine",
|
|
226
239
|
name: "Marketing Content Engine",
|
|
227
240
|
description: "Content marketing operating template for publishing, distribution, and analytics loops.",
|
|
228
|
-
version: "1.0.
|
|
241
|
+
version: "1.0.1",
|
|
229
242
|
status: "published",
|
|
230
243
|
visibility: "company",
|
|
231
244
|
variables: [
|
|
@@ -276,6 +289,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
276
289
|
{
|
|
277
290
|
key: "head-of-marketing",
|
|
278
291
|
role: "Head of Marketing",
|
|
292
|
+
roleKey: "cmo",
|
|
293
|
+
title: "Head of Marketing",
|
|
294
|
+
capabilities:
|
|
295
|
+
"Owns marketing narrative, cross-functional alignment, and weekly performance decisions for pipeline growth.",
|
|
279
296
|
name: "Head of Marketing",
|
|
280
297
|
providerType: "codex",
|
|
281
298
|
heartbeatCron: "*/20 * * * *",
|
|
@@ -308,6 +325,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
308
325
|
{
|
|
309
326
|
key: "content-strategist",
|
|
310
327
|
role: "Content Strategist",
|
|
328
|
+
roleKey: "general",
|
|
329
|
+
title: "Content Strategist",
|
|
330
|
+
capabilities:
|
|
331
|
+
"Builds editorial calendars, briefs, and topic architecture tied to audience segments and revenue goals.",
|
|
311
332
|
name: "Content Strategist",
|
|
312
333
|
managerAgentKey: "head-of-marketing",
|
|
313
334
|
providerType: "codex",
|
|
@@ -337,6 +358,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
337
358
|
{
|
|
338
359
|
key: "content-writer",
|
|
339
360
|
role: "Content Writer",
|
|
361
|
+
roleKey: "general",
|
|
362
|
+
title: "Content Writer",
|
|
363
|
+
capabilities:
|
|
364
|
+
"Produces channel-ready drafts, headline and CTA options, and repurposing notes aligned to campaign intent.",
|
|
340
365
|
name: "Content Writer",
|
|
341
366
|
managerAgentKey: "head-of-marketing",
|
|
342
367
|
providerType: "codex",
|
|
@@ -367,6 +392,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
367
392
|
{
|
|
368
393
|
key: "distribution-manager",
|
|
369
394
|
role: "Distribution Manager",
|
|
395
|
+
roleKey: "general",
|
|
396
|
+
title: "Distribution Manager",
|
|
397
|
+
capabilities:
|
|
398
|
+
"Distributes and repurposes assets across channels with tracking discipline and weekly performance reporting.",
|
|
370
399
|
name: "Distribution Manager",
|
|
371
400
|
managerAgentKey: "head-of-marketing",
|
|
372
401
|
providerType: "codex",
|
|
@@ -482,6 +511,11 @@ export async function ensureCompanyBuiltinTemplateDefaults(db: BopoDb, companyId
|
|
|
482
511
|
version: definition.version,
|
|
483
512
|
manifestJson: JSON.stringify(manifest)
|
|
484
513
|
});
|
|
514
|
+
await updateTemplate(db, {
|
|
515
|
+
companyId,
|
|
516
|
+
id: template.id,
|
|
517
|
+
currentVersion: definition.version
|
|
518
|
+
});
|
|
485
519
|
}
|
|
486
520
|
}
|
|
487
521
|
}
|