@agentprojectcontext/apx 1.56.2 → 1.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/apc/paths.js +7 -0
- package/src/core/stores/conversations.js +10 -0
- package/src/core/stores/messages.js +37 -6
- package/src/core/stores/organization.js +152 -0
- package/src/core/stores/project-files.js +199 -0
- package/src/core/stores/tasks.js +36 -3
- package/src/host/daemon/api/agents.js +22 -2
- package/src/host/daemon/api/conversations.js +20 -2
- package/src/host/daemon/api/files-project.js +99 -0
- package/src/host/daemon/api/organization.js +88 -0
- package/src/host/daemon/api/shared.js +7 -0
- package/src/host/daemon/api/tasks.js +14 -0
- package/src/host/daemon/api.js +4 -0
- package/src/interfaces/cli/commands/org.js +77 -0
- package/src/interfaces/cli/index.js +48 -0
- package/src/interfaces/web/dist/assets/index-Cl0WXtxF.css +1 -0
- package/src/interfaces/web/dist/assets/index-DPAuXATr.js +705 -0
- package/src/interfaces/web/dist/assets/index-DPAuXATr.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/App.tsx +1 -1
- package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
- package/src/interfaces/web/src/components/chat/ChatList.tsx +86 -65
- package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
- package/src/interfaces/web/src/components/common/TabNav.tsx +1 -1
- package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
- package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
- package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
- package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
- package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
- package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
- package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
- package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
- package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
- package/src/interfaces/web/src/hooks/useChat.ts +39 -7
- package/src/interfaces/web/src/i18n/en.ts +113 -3
- package/src/interfaces/web/src/i18n/es.ts +113 -3
- package/src/interfaces/web/src/lib/api/conversations.ts +6 -0
- package/src/interfaces/web/src/lib/api/organization.ts +18 -0
- package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
- package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
- package/src/interfaces/web/src/lib/api.ts +2 -0
- package/src/interfaces/web/src/lib/slug.ts +11 -0
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +22 -3
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +1 -1
- package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
- package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +136 -36
- package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
- package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
- package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
- package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
- package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
- package/src/interfaces/web/src/types/daemon.ts +68 -0
- package/src/interfaces/web/dist/assets/index-CAUezTBY.css +0 -1
- package/src/interfaces/web/dist/assets/index-DaE_memX.js +0 -651
- package/src/interfaces/web/dist/assets/index-DaE_memX.js.map +0 -1
package/package.json
CHANGED
package/src/core/apc/paths.js
CHANGED
|
@@ -18,6 +18,7 @@ export const APC_SKILLS_DIR = "skills";
|
|
|
18
18
|
export const APC_COMMANDS_DIR = "commands";
|
|
19
19
|
export const APC_NOTES_DIR = "notes";
|
|
20
20
|
export const APC_MCPS_FILE = "mcps.json";
|
|
21
|
+
export const APC_ORGANIZATION_FILE = "organization.json";
|
|
21
22
|
export const APC_REMOVED_FILE = ".removed.json";
|
|
22
23
|
export const AGENTS_MD = "AGENTS.md";
|
|
23
24
|
|
|
@@ -77,6 +78,12 @@ export function apcMcpsFile(root) {
|
|
|
77
78
|
return path.join(root, APC_DIR, APC_MCPS_FILE);
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// Org structure (areas + roles) for company/enterprise projects. Committed,
|
|
82
|
+
// no secrets — travels with the project like agents do.
|
|
83
|
+
export function apcOrganizationFile(root) {
|
|
84
|
+
return path.join(root, APC_DIR, APC_ORGANIZATION_FILE);
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
export function agentsMdFile(root) {
|
|
81
88
|
return path.join(root, AGENTS_MD);
|
|
82
89
|
}
|
|
@@ -91,6 +91,16 @@ export function readConversation(storagePath, agentSlug, idOrFilename) {
|
|
|
91
91
|
return { ...parseConversation(fs.readFileSync(p, "utf8")), path: p };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// Delete a conversation file. Filesystem is source of truth, so unlinking the
|
|
95
|
+
// markdown removes it from the sidebar list on the next fetch. Returns false
|
|
96
|
+
// when there is nothing to delete (already gone / bad id).
|
|
97
|
+
export function deleteConversation(storagePath, agentSlug, idOrFilename) {
|
|
98
|
+
const p = conversationPath(storagePath, agentSlug, idOrFilename);
|
|
99
|
+
if (!fs.existsSync(p)) return false;
|
|
100
|
+
fs.unlinkSync(p);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
94
104
|
export function listConversations(storagePath, agentSlug) {
|
|
95
105
|
const dir = path.join(storagePath, "agents", agentSlug, "conversations");
|
|
96
106
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -644,6 +644,11 @@ export function listGlobalThreads({ channels, _globalMessagesDir } = {}) {
|
|
|
644
644
|
|
|
645
645
|
// Read one channel+day thread shaped for the web chat viewer:
|
|
646
646
|
// { id, channel, messages: [{ role, content, ts }] } — or null when missing.
|
|
647
|
+
// Tool records are INCLUDED (role:"tool" with structured tool/args/result from
|
|
648
|
+
// meta) so the web viewer can render tool executions the same way the live
|
|
649
|
+
// stream does. They're persisted by every channel (e.g. telegram reply.js) but
|
|
650
|
+
// never sent to the channel itself; dropping them here is what made Telegram
|
|
651
|
+
// tool calls invisible in the web chat even though they were on disk.
|
|
647
652
|
export function readGlobalThread({ channel, date, _globalMessagesDir } = {}) {
|
|
648
653
|
if (!CHANNEL_NAME_RE.test(String(channel || ""))) return null;
|
|
649
654
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ""))) return null;
|
|
@@ -651,15 +656,41 @@ export function readGlobalThread({ channel, date, _globalMessagesDir } = {}) {
|
|
|
651
656
|
const file = path.join(base, channel, `${date}.jsonl`);
|
|
652
657
|
if (!fs.existsSync(file)) return null;
|
|
653
658
|
const messages = parseDayJsonl(fs.readFileSync(file, "utf8"))
|
|
654
|
-
.filter((r) => r.type === "user" || r.type === "agent")
|
|
655
|
-
.map((r) =>
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
659
|
+
.filter((r) => r.type === "user" || r.type === "agent" || r.type === "tool")
|
|
660
|
+
.map((r) => {
|
|
661
|
+
if (r.type === "tool") {
|
|
662
|
+
return {
|
|
663
|
+
role: "tool",
|
|
664
|
+
content: r.body || "",
|
|
665
|
+
ts: r.ts,
|
|
666
|
+
tool: r.meta?.tool || r.meta?.tool_name || r.actor_id || "tool",
|
|
667
|
+
args: r.meta?.args,
|
|
668
|
+
result: r.meta?.result,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
role: r.type === "user" ? "user" : "assistant",
|
|
673
|
+
content: r.body || "",
|
|
674
|
+
ts: r.ts,
|
|
675
|
+
};
|
|
676
|
+
});
|
|
660
677
|
return { id: date, channel, messages };
|
|
661
678
|
}
|
|
662
679
|
|
|
680
|
+
// Delete one channel+day thread by removing its JSONL file. The global ledger
|
|
681
|
+
// is FS-backed (listGlobalThreads/readGlobalThread read files directly), so
|
|
682
|
+
// unlinking the day-file drops the thread from the sidebar. Returns false for a
|
|
683
|
+
// bad channel/date or a file that is already gone.
|
|
684
|
+
export function deleteGlobalThread({ channel, date, _globalMessagesDir } = {}) {
|
|
685
|
+
if (!CHANNEL_NAME_RE.test(String(channel || ""))) return false;
|
|
686
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ""))) return false;
|
|
687
|
+
const base = _globalMessagesDir || GLOBAL_MESSAGES_DIR;
|
|
688
|
+
const file = path.join(base, channel, `${date}.jsonl`);
|
|
689
|
+
if (!fs.existsSync(file)) return false;
|
|
690
|
+
fs.unlinkSync(file);
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
|
|
663
694
|
// Wipe the cache and re-populate from APX project messages. Reads BOTH `.jsonl`
|
|
664
695
|
// (current format) and `.md` (legacy). Called by rebuild.
|
|
665
696
|
export function rebuildMessagesFromFs(db, projectRoot) {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Organization structure (areas + roles) for a project.
|
|
2
|
+
//
|
|
3
|
+
// Only meaningful for "company"/enterprise-shaped projects, but the store is
|
|
4
|
+
// generic: any project can carry an org chart. Persisted as committed JSON at
|
|
5
|
+
// <root>/.apc/organization.json
|
|
6
|
+
// so it travels with the project and is diffable (same model as agents). No
|
|
7
|
+
// secrets ever live here.
|
|
8
|
+
//
|
|
9
|
+
// Shape on disk:
|
|
10
|
+
// {
|
|
11
|
+
// "areas": [{ slug, name, goal }],
|
|
12
|
+
// "roles": [{ slug, name, area, description }]
|
|
13
|
+
// }
|
|
14
|
+
//
|
|
15
|
+
// `role.area` references an area slug (or null for a general/unassigned role).
|
|
16
|
+
// Deleting an area detaches its roles (sets their `area` to null) rather than
|
|
17
|
+
// cascading a delete — losing a role definition because its grouping changed
|
|
18
|
+
// would be surprising.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { apcOrganizationFile } from "../apc/paths.js";
|
|
22
|
+
|
|
23
|
+
export const ORG_SLUG_RE = /^[a-z][a-z0-9_-]*$/;
|
|
24
|
+
|
|
25
|
+
// Derive a slug from a free-text name (kebab-case). Mirrors the front-end's
|
|
26
|
+
// auto-slug behavior so a name typed in either surface yields the same slug.
|
|
27
|
+
export function slugifyName(name) {
|
|
28
|
+
return String(name || "")
|
|
29
|
+
.trim()
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
32
|
+
.replace(/^-+|-+$/g, "")
|
|
33
|
+
.replace(/-{2,}/g, "-");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function emptyOrg() {
|
|
37
|
+
return { areas: [], roles: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readOrganization(root) {
|
|
41
|
+
const file = apcOrganizationFile(root);
|
|
42
|
+
if (!fs.existsSync(file)) return emptyOrg();
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
45
|
+
return {
|
|
46
|
+
areas: Array.isArray(raw.areas) ? raw.areas : [],
|
|
47
|
+
roles: Array.isArray(raw.roles) ? raw.roles : [],
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
// A corrupt file shouldn't take down the whole project view.
|
|
51
|
+
return emptyOrg();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeOrganization(root, org) {
|
|
56
|
+
const file = apcOrganizationFile(root);
|
|
57
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
+
fs.writeFileSync(file, JSON.stringify(org, null, 2) + "\n");
|
|
59
|
+
return org;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normSlug(input, fallbackName) {
|
|
63
|
+
const raw = input && String(input).trim() ? input : slugifyName(fallbackName);
|
|
64
|
+
const slug = slugifyName(raw);
|
|
65
|
+
if (!slug || !ORG_SLUG_RE.test(slug)) {
|
|
66
|
+
throw new Error(`invalid slug: ${slug || "(empty)"}`);
|
|
67
|
+
}
|
|
68
|
+
return slug;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────── Areas ─────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export function createArea(root, { name, slug, goal } = {}) {
|
|
74
|
+
if (!name || !String(name).trim()) throw new Error("area name required");
|
|
75
|
+
const org = readOrganization(root);
|
|
76
|
+
const areaSlug = normSlug(slug, name);
|
|
77
|
+
if (org.areas.some((a) => a.slug === areaSlug))
|
|
78
|
+
throw new Error(`area ${areaSlug} already exists`);
|
|
79
|
+
const area = { slug: areaSlug, name: String(name).trim(), goal: goal ? String(goal) : null };
|
|
80
|
+
org.areas.push(area);
|
|
81
|
+
writeOrganization(root, org);
|
|
82
|
+
return area;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function updateArea(root, slug, patch = {}) {
|
|
86
|
+
const org = readOrganization(root);
|
|
87
|
+
const area = org.areas.find((a) => a.slug === slug);
|
|
88
|
+
if (!area) return null;
|
|
89
|
+
if (patch.name !== undefined) area.name = String(patch.name).trim();
|
|
90
|
+
if (patch.goal !== undefined) area.goal = patch.goal ? String(patch.goal) : null;
|
|
91
|
+
writeOrganization(root, org);
|
|
92
|
+
return area;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function removeArea(root, slug) {
|
|
96
|
+
const org = readOrganization(root);
|
|
97
|
+
const idx = org.areas.findIndex((a) => a.slug === slug);
|
|
98
|
+
if (idx === -1) return false;
|
|
99
|
+
org.areas.splice(idx, 1);
|
|
100
|
+
// Detach roles that pointed at this area (see header note).
|
|
101
|
+
for (const r of org.roles) if (r.area === slug) r.area = null;
|
|
102
|
+
writeOrganization(root, org);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─────────────────────────────── Roles ─────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export function createRole(root, { name, slug, area, description } = {}) {
|
|
109
|
+
if (!name || !String(name).trim()) throw new Error("role name required");
|
|
110
|
+
const org = readOrganization(root);
|
|
111
|
+
const roleSlug = normSlug(slug, name);
|
|
112
|
+
if (org.roles.some((r) => r.slug === roleSlug))
|
|
113
|
+
throw new Error(`role ${roleSlug} already exists`);
|
|
114
|
+
const areaSlug = area ? String(area) : null;
|
|
115
|
+
if (areaSlug && !org.areas.some((a) => a.slug === areaSlug))
|
|
116
|
+
throw new Error(`area ${areaSlug} not found`);
|
|
117
|
+
const role = {
|
|
118
|
+
slug: roleSlug,
|
|
119
|
+
name: String(name).trim(),
|
|
120
|
+
area: areaSlug,
|
|
121
|
+
description: description ? String(description) : null,
|
|
122
|
+
};
|
|
123
|
+
org.roles.push(role);
|
|
124
|
+
writeOrganization(root, org);
|
|
125
|
+
return role;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function updateRole(root, slug, patch = {}) {
|
|
129
|
+
const org = readOrganization(root);
|
|
130
|
+
const role = org.roles.find((r) => r.slug === slug);
|
|
131
|
+
if (!role) return null;
|
|
132
|
+
if (patch.name !== undefined) role.name = String(patch.name).trim();
|
|
133
|
+
if (patch.description !== undefined)
|
|
134
|
+
role.description = patch.description ? String(patch.description) : null;
|
|
135
|
+
if (patch.area !== undefined) {
|
|
136
|
+
const areaSlug = patch.area ? String(patch.area) : null;
|
|
137
|
+
if (areaSlug && !org.areas.some((a) => a.slug === areaSlug))
|
|
138
|
+
throw new Error(`area ${areaSlug} not found`);
|
|
139
|
+
role.area = areaSlug;
|
|
140
|
+
}
|
|
141
|
+
writeOrganization(root, org);
|
|
142
|
+
return role;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function removeRole(root, slug) {
|
|
146
|
+
const org = readOrganization(root);
|
|
147
|
+
const idx = org.roles.findIndex((r) => r.slug === slug);
|
|
148
|
+
if (idx === -1) return false;
|
|
149
|
+
org.roles.splice(idx, 1);
|
|
150
|
+
writeOrganization(root, org);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Project file browser — a safe, sandboxed view of a project's own files.
|
|
2
|
+
//
|
|
3
|
+
// Powers the web /files browser and the /docs editor (same store, different
|
|
4
|
+
// root). Everything is resolved *inside* the project root; any path that would
|
|
5
|
+
// escape it throws. This is intentionally NOT a general filesystem API — it
|
|
6
|
+
// only ever touches files under one project.
|
|
7
|
+
//
|
|
8
|
+
// Two roots matter:
|
|
9
|
+
// - project root — the repo directory (whole-project /files browser)
|
|
10
|
+
// - docs root — a subfolder (config `docs.root`, default "docs") used by
|
|
11
|
+
// the /docs editor, so specs/casework live in one place
|
|
12
|
+
// (like Appsi's work/ folder of case folders).
|
|
13
|
+
//
|
|
14
|
+
// Reads classify by extension: text is returned inline (utf8); images under a
|
|
15
|
+
// size cap are returned base64 so the authenticated JSON API can render them
|
|
16
|
+
// without a separate unauthenticated asset route; anything else is reported as
|
|
17
|
+
// binary with no body (the UI shows metadata + a download affordance).
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
// Directories that are never useful to browse and would blow the node budget.
|
|
22
|
+
const SKIP_DIRS = new Set([
|
|
23
|
+
"node_modules", ".git", "dist", "build", ".next", ".turbo", ".nuxt",
|
|
24
|
+
"coverage", ".cache", ".venv", "venv", "__pycache__", ".pytest_cache",
|
|
25
|
+
".idea", ".vscode", ".DS_Store",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const TEXT_EXTS = new Set([
|
|
29
|
+
"md", "markdown", "mdx", "txt", "text", "rst", "adoc",
|
|
30
|
+
"js", "jsx", "ts", "tsx", "mjs", "cjs", "json", "jsonc", "json5",
|
|
31
|
+
"css", "scss", "sass", "less", "html", "htm", "xml", "svg", "vue", "svelte",
|
|
32
|
+
"py", "rb", "go", "rs", "java", "kt", "c", "h", "cpp", "hpp", "cc",
|
|
33
|
+
"cs", "php", "swift", "sh", "bash", "zsh", "fish", "sql", "graphql", "gql",
|
|
34
|
+
"yml", "yaml", "toml", "ini", "cfg", "conf", "env", "properties",
|
|
35
|
+
"csv", "tsv", "log", "gitignore", "dockerignore", "dockerfile", "makefile",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]);
|
|
39
|
+
|
|
40
|
+
const IMAGE_MIME = {
|
|
41
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
|
|
42
|
+
webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon",
|
|
43
|
+
avif: "image/avif",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Guardrails.
|
|
47
|
+
const MAX_NODES = 4000; // total tree entries returned
|
|
48
|
+
const MAX_TEXT_BYTES = 2_000_000; // 2 MB inline text ceiling
|
|
49
|
+
const MAX_IMAGE_BYTES = 5_000_000; // 5 MB base64 image ceiling
|
|
50
|
+
|
|
51
|
+
function extOf(name) {
|
|
52
|
+
const base = name.toLowerCase();
|
|
53
|
+
if (base === "dockerfile" || base === "makefile") return base;
|
|
54
|
+
const dot = base.lastIndexOf(".");
|
|
55
|
+
return dot === -1 ? "" : base.slice(dot + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function classifyKind(name) {
|
|
59
|
+
const ext = extOf(name);
|
|
60
|
+
if (IMAGE_EXTS.has(ext)) return "image";
|
|
61
|
+
if (ext === "md" || ext === "markdown" || ext === "mdx") return "markdown";
|
|
62
|
+
if (TEXT_EXTS.has(ext)) return "text";
|
|
63
|
+
return "binary";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Resolve `rel` inside `base`, refusing anything that escapes the sandbox.
|
|
67
|
+
function resolveWithin(base, rel = "") {
|
|
68
|
+
const root = path.resolve(base);
|
|
69
|
+
const clean = String(rel || "").replace(/^[/\\]+/, "");
|
|
70
|
+
const abs = path.resolve(root, clean);
|
|
71
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
72
|
+
throw new Error("path escapes the project root");
|
|
73
|
+
}
|
|
74
|
+
return { root, abs };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The docs root for a project (config.docs.root, default "docs"), as a subpath
|
|
78
|
+
// relative to the project root. Sanitized so it can't escape.
|
|
79
|
+
export function docsSubdir(config) {
|
|
80
|
+
const raw = config?.docs?.root;
|
|
81
|
+
const sub = raw && typeof raw === "string" ? raw : "docs";
|
|
82
|
+
return sub.replace(/^[/\\]+/, "").replace(/\.\.(?:[/\\]|$)/g, "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Recursively build a nested tree under `absDir`. Dirs first, then files, each
|
|
86
|
+
// alphabetical. `relPrefix` is the path relative to the sandbox root, which is
|
|
87
|
+
// what callers pass back to read/write.
|
|
88
|
+
function walk(absDir, relPrefix, budget) {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const dirs = [];
|
|
96
|
+
const files = [];
|
|
97
|
+
for (const ent of entries) {
|
|
98
|
+
const name = ent.name;
|
|
99
|
+
if (name.startsWith(".")) continue; // hide dotfiles/dirs
|
|
100
|
+
if (ent.isDirectory() && SKIP_DIRS.has(name)) continue;
|
|
101
|
+
(ent.isDirectory() ? dirs : files).push(ent);
|
|
102
|
+
}
|
|
103
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
104
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
105
|
+
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const ent of [...dirs, ...files]) {
|
|
108
|
+
if (budget.count >= MAX_NODES) {
|
|
109
|
+
budget.truncated = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
budget.count += 1;
|
|
113
|
+
const rel = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
|
|
114
|
+
if (ent.isDirectory()) {
|
|
115
|
+
out.push({ name: ent.name, path: rel, type: "dir", children: walk(path.join(absDir, ent.name), rel, budget) });
|
|
116
|
+
} else {
|
|
117
|
+
out.push({ name: ent.name, path: rel, type: "file", kind: classifyKind(ent.name) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// List a tree. `opts.subdir` scopes the sandbox to a subfolder (used for docs).
|
|
124
|
+
// Returns { root, subdir, tree, truncated }. A missing dir yields an empty tree
|
|
125
|
+
// rather than an error — the UI treats that as "nothing here yet".
|
|
126
|
+
export function listTree(projectRoot, opts = {}) {
|
|
127
|
+
const subdir = opts.subdir ? String(opts.subdir) : "";
|
|
128
|
+
const { abs } = resolveWithin(projectRoot, subdir);
|
|
129
|
+
const budget = { count: 0, truncated: false };
|
|
130
|
+
const tree = fs.existsSync(abs) && fs.statSync(abs).isDirectory()
|
|
131
|
+
? walk(abs, "", budget)
|
|
132
|
+
: [];
|
|
133
|
+
return { root: projectRoot, subdir, tree, truncated: budget.truncated };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Read a single file. `opts.subdir` scopes the sandbox (docs). Returns metadata
|
|
137
|
+
// plus the body when it's text/image; binary bodies are omitted.
|
|
138
|
+
export function readFile(projectRoot, relPath, opts = {}) {
|
|
139
|
+
const base = opts.subdir ? path.join(projectRoot, opts.subdir) : projectRoot;
|
|
140
|
+
const { abs } = resolveWithin(base, relPath);
|
|
141
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
|
|
142
|
+
const stat = fs.statSync(abs);
|
|
143
|
+
const name = path.basename(abs);
|
|
144
|
+
const kind = classifyKind(name);
|
|
145
|
+
const common = {
|
|
146
|
+
path: relPath.replace(/^[/\\]+/, ""),
|
|
147
|
+
name,
|
|
148
|
+
kind,
|
|
149
|
+
size: stat.size,
|
|
150
|
+
modified: stat.mtime.toISOString(),
|
|
151
|
+
};
|
|
152
|
+
if (kind === "image") {
|
|
153
|
+
if (stat.size > MAX_IMAGE_BYTES) return { ...common, encoding: "binary", content: null, too_large: true };
|
|
154
|
+
const ext = extOf(name);
|
|
155
|
+
return {
|
|
156
|
+
...common,
|
|
157
|
+
encoding: "base64",
|
|
158
|
+
mime: IMAGE_MIME[ext] || "application/octet-stream",
|
|
159
|
+
content: fs.readFileSync(abs).toString("base64"),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (kind === "binary") {
|
|
163
|
+
return { ...common, encoding: "binary", content: null };
|
|
164
|
+
}
|
|
165
|
+
if (stat.size > MAX_TEXT_BYTES) {
|
|
166
|
+
return { ...common, encoding: "binary", content: null, too_large: true };
|
|
167
|
+
}
|
|
168
|
+
return { ...common, encoding: "utf8", content: fs.readFileSync(abs, "utf8") };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Write (create or overwrite) a text file, creating parent dirs as needed.
|
|
172
|
+
export function writeFile(projectRoot, relPath, content, opts = {}) {
|
|
173
|
+
const base = opts.subdir ? path.join(projectRoot, opts.subdir) : projectRoot;
|
|
174
|
+
const { abs } = resolveWithin(base, relPath);
|
|
175
|
+
if (typeof content !== "string") throw new Error("content must be a string");
|
|
176
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory())
|
|
177
|
+
throw new Error("path is a directory");
|
|
178
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
179
|
+
fs.writeFileSync(abs, content);
|
|
180
|
+
return { ok: true, path: relPath.replace(/^[/\\]+/, ""), bytes: Buffer.byteLength(content, "utf8") };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Create a directory (mkdir -p).
|
|
184
|
+
export function makeDir(projectRoot, relPath, opts = {}) {
|
|
185
|
+
const base = opts.subdir ? path.join(projectRoot, opts.subdir) : projectRoot;
|
|
186
|
+
const { abs } = resolveWithin(base, relPath);
|
|
187
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
188
|
+
return { ok: true, path: relPath.replace(/^[/\\]+/, "") };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Delete a file or directory (recursive). Refuses to delete the sandbox root.
|
|
192
|
+
export function removeEntry(projectRoot, relPath, opts = {}) {
|
|
193
|
+
const base = opts.subdir ? path.join(projectRoot, opts.subdir) : projectRoot;
|
|
194
|
+
const { root, abs } = resolveWithin(base, relPath);
|
|
195
|
+
if (abs === root) throw new Error("refusing to delete the root");
|
|
196
|
+
if (!fs.existsSync(abs)) return false;
|
|
197
|
+
fs.rmSync(abs, { recursive: true, force: true });
|
|
198
|
+
return true;
|
|
199
|
+
}
|
package/src/core/stores/tasks.js
CHANGED
|
@@ -19,6 +19,16 @@ import path from "node:path";
|
|
|
19
19
|
import { nowIso } from "../util/time.js";
|
|
20
20
|
import { shortId as makeShortId } from "../util/ids.js";
|
|
21
21
|
|
|
22
|
+
// Workflow sub-status for an *open* task. Orthogonal to `state`
|
|
23
|
+
// (open/done/dropped): `state` is the storage lifecycle, `status` is how an
|
|
24
|
+
// open task is progressing. `blocked` means it's waiting on a human/agent.
|
|
25
|
+
export const TASK_STATUSES = Object.freeze(["pending", "running", "in_review", "blocked"]);
|
|
26
|
+
export const DEFAULT_TASK_STATUS = "pending";
|
|
27
|
+
|
|
28
|
+
function normalizeStatus(v) {
|
|
29
|
+
return TASK_STATUSES.includes(v) ? v : DEFAULT_TASK_STATUS;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
function tasksDir(storagePath) {
|
|
23
33
|
return path.join(storagePath, "tasks");
|
|
24
34
|
}
|
|
@@ -72,12 +82,15 @@ function projectState(events) {
|
|
|
72
82
|
created_at: ev.ts,
|
|
73
83
|
updated_at: ev.ts,
|
|
74
84
|
state: "open",
|
|
85
|
+
status: normalizeStatus(ev.status),
|
|
75
86
|
title: ev.title || "",
|
|
76
87
|
body: ev.body || null,
|
|
77
88
|
tags: Array.isArray(ev.tags) ? [...ev.tags] : [],
|
|
78
89
|
due: ev.due || null,
|
|
79
90
|
agent: ev.agent || null,
|
|
80
91
|
source: ev.source || null,
|
|
92
|
+
created_by: ev.created_by || null,
|
|
93
|
+
thread: ev.thread || null,
|
|
81
94
|
meta: ev.meta && typeof ev.meta === "object" ? { ...ev.meta } : {},
|
|
82
95
|
});
|
|
83
96
|
break;
|
|
@@ -87,7 +100,7 @@ function projectState(events) {
|
|
|
87
100
|
const patch = ev.patch && typeof ev.patch === "object" ? ev.patch : {};
|
|
88
101
|
for (const k of Object.keys(patch)) {
|
|
89
102
|
if (k === "id" || k === "state" || k === "created_at") continue;
|
|
90
|
-
existing[k] = patch[k];
|
|
103
|
+
existing[k] = k === "status" ? normalizeStatus(patch[k]) : patch[k];
|
|
91
104
|
}
|
|
92
105
|
existing.updated_at = ev.ts;
|
|
93
106
|
break;
|
|
@@ -141,10 +154,13 @@ export function createTask(storagePath, fields) {
|
|
|
141
154
|
op: "create",
|
|
142
155
|
title: fields.title.trim(),
|
|
143
156
|
body: fields.body || null,
|
|
157
|
+
status: normalizeStatus(fields.status),
|
|
144
158
|
tags: Array.isArray(fields.tags) ? fields.tags.filter((t) => typeof t === "string") : [],
|
|
145
159
|
due: fields.due || null,
|
|
146
160
|
agent: fields.agent || null,
|
|
147
161
|
source: fields.source || null,
|
|
162
|
+
created_by: fields.created_by || null,
|
|
163
|
+
thread: fields.thread || null,
|
|
148
164
|
meta: fields.meta && typeof fields.meta === "object" ? fields.meta : {},
|
|
149
165
|
};
|
|
150
166
|
appendEvent(storagePath, ev);
|
|
@@ -207,6 +223,19 @@ export function patchTask(storagePath, idOrPrefix, patch) {
|
|
|
207
223
|
return getTask(storagePath, existing.id);
|
|
208
224
|
}
|
|
209
225
|
|
|
226
|
+
/** Set the workflow status (pending/running/in_review/blocked) of an open task. */
|
|
227
|
+
export function setTaskStatus(storagePath, idOrPrefix, status) {
|
|
228
|
+
const existing = getTask(storagePath, idOrPrefix);
|
|
229
|
+
if (!existing) return null;
|
|
230
|
+
appendEvent(storagePath, {
|
|
231
|
+
id: existing.id,
|
|
232
|
+
ts: nowIso(),
|
|
233
|
+
op: "update",
|
|
234
|
+
patch: { status: normalizeStatus(status) },
|
|
235
|
+
});
|
|
236
|
+
return getTask(storagePath, existing.id);
|
|
237
|
+
}
|
|
238
|
+
|
|
210
239
|
/** Mark done. */
|
|
211
240
|
export function doneTask(storagePath, idOrPrefix, by = null) {
|
|
212
241
|
const existing = getTask(storagePath, idOrPrefix);
|
|
@@ -249,11 +278,15 @@ export function reopenTask(storagePath, idOrPrefix) {
|
|
|
249
278
|
export function countTasks(storagePath) {
|
|
250
279
|
const tasks = [...projectState(readAllEvents(storagePath)).values()];
|
|
251
280
|
const today = new Date().toISOString().slice(0, 10);
|
|
281
|
+
const open = tasks.filter((t) => t.state === "open");
|
|
282
|
+
const byStatus = {};
|
|
283
|
+
for (const s of TASK_STATUSES) byStatus[s] = open.filter((t) => t.status === s).length;
|
|
252
284
|
return {
|
|
253
|
-
open:
|
|
285
|
+
open: open.length,
|
|
254
286
|
done: tasks.filter((t) => t.state === "done").length,
|
|
255
287
|
dropped: tasks.filter((t) => t.state === "dropped").length,
|
|
256
|
-
overdue:
|
|
288
|
+
overdue: open.filter((t) => t.due && t.due < today).length,
|
|
257
289
|
total: tasks.length,
|
|
290
|
+
status: byStatus,
|
|
258
291
|
};
|
|
259
292
|
}
|
|
@@ -24,6 +24,17 @@ import {
|
|
|
24
24
|
} from "#core/agent/memory.js";
|
|
25
25
|
import { agentToResponse } from "./shared.js";
|
|
26
26
|
import { normalizeVaultPatch } from "#core/apc/agents-vault.js";
|
|
27
|
+
import { PERMISSION_MODES } from "#core/constants/permissions.js";
|
|
28
|
+
|
|
29
|
+
// Autonomy mirrors the super-agent permission modes (total/automatico/permiso).
|
|
30
|
+
// An invalid value is dropped rather than persisted so a typo can't silently
|
|
31
|
+
// widen an agent's autonomy.
|
|
32
|
+
const AUTONOMY_VALUES = new Set(Object.values(PERMISSION_MODES));
|
|
33
|
+
function normalizeAutonomy(v) {
|
|
34
|
+
if (v === undefined) return undefined;
|
|
35
|
+
if (v === null || v === "") return null;
|
|
36
|
+
return AUTONOMY_VALUES.has(v) ? v : undefined;
|
|
37
|
+
}
|
|
27
38
|
|
|
28
39
|
export function register(app, { projects, project }) {
|
|
29
40
|
// Vault = global agent templates. Two-layer: bundled defaults shipped with
|
|
@@ -128,14 +139,17 @@ export function register(app, { projects, project }) {
|
|
|
128
139
|
app.post("/projects/:pid/agents", (req, res) => {
|
|
129
140
|
const p = project(req, res);
|
|
130
141
|
if (!p) return;
|
|
131
|
-
const {
|
|
132
|
-
|
|
142
|
+
const {
|
|
143
|
+
slug, role, model, skills, language, description, tools, is_master,
|
|
144
|
+
parent, type, area, emoji, autonomy,
|
|
145
|
+
} = req.body || {};
|
|
133
146
|
if (!slug) return res.status(400).json({ error: "slug required" });
|
|
134
147
|
if (!/^[a-z][a-z0-9_-]*$/.test(slug))
|
|
135
148
|
return res.status(400).json({ error: "invalid slug" });
|
|
136
149
|
const existing = readAgents(p.path).find((a) => a.slug === slug);
|
|
137
150
|
if (existing)
|
|
138
151
|
return res.status(400).json({ error: `agent ${slug} already exists` });
|
|
152
|
+
const autonomyVal = normalizeAutonomy(autonomy);
|
|
139
153
|
try {
|
|
140
154
|
writeAgentFile(p.path, slug, {
|
|
141
155
|
Role: role || null,
|
|
@@ -146,6 +160,10 @@ export function register(app, { projects, project }) {
|
|
|
146
160
|
Tools: tools || [],
|
|
147
161
|
Master: is_master ? true : null,
|
|
148
162
|
Parent: parent || null,
|
|
163
|
+
Type: type || null,
|
|
164
|
+
Area: area || null,
|
|
165
|
+
Emoji: emoji || null,
|
|
166
|
+
Autonomy: autonomyVal || null,
|
|
149
167
|
});
|
|
150
168
|
ensureAgentDir(p.path, slug);
|
|
151
169
|
ensureAgentRuntimeDir(p, slug);
|
|
@@ -179,6 +197,8 @@ export function register(app, { projects, project }) {
|
|
|
179
197
|
setStr("Parent", b.parent);
|
|
180
198
|
setStr("Type", b.type);
|
|
181
199
|
setStr("Area", b.area);
|
|
200
|
+
setStr("Emoji", b.emoji);
|
|
201
|
+
setStr("Autonomy", normalizeAutonomy(b.autonomy));
|
|
182
202
|
if (b.skills !== undefined) fields.Skills = Array.isArray(b.skills) ? b.skills : [];
|
|
183
203
|
if (b.tools !== undefined) fields.Tools = Array.isArray(b.tools) ? b.tools : [];
|
|
184
204
|
if (b.is_master !== undefined) {
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// POST /projects/:pid/agents/:slug/conversations/:id/compact
|
|
8
8
|
// POST /projects/:pid/send (agent-to-agent)
|
|
9
9
|
import { readAgents } from "#core/apc/parser.js";
|
|
10
|
-
import { listConversations, readConversation } from "#core/stores/conversations.js";
|
|
11
|
-
import { listGlobalThreads, readGlobalThread } from "#core/stores/messages.js";
|
|
10
|
+
import { listConversations, readConversation, deleteConversation } from "#core/stores/conversations.js";
|
|
11
|
+
import { listGlobalThreads, readGlobalThread, deleteGlobalThread } from "#core/stores/messages.js";
|
|
12
12
|
import { compactConversation } from "#core/stores/conversations-compactor.js";
|
|
13
13
|
import { replyAsAgent } from "#core/agent/a2a/reply.js";
|
|
14
14
|
import { nowIso } from "./shared.js";
|
|
@@ -51,6 +51,16 @@ export function register(app, { project, config }) {
|
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
app.delete("/projects/:pid/agents/:slug/conversations/:id", (req, res) => {
|
|
55
|
+
const p = project(req, res);
|
|
56
|
+
if (!p) return;
|
|
57
|
+
if (!agentResolvable(p, req.params.slug))
|
|
58
|
+
return res.status(404).json({ error: "agent not found" });
|
|
59
|
+
const ok = deleteConversation(p.storagePath, req.params.slug, req.params.id);
|
|
60
|
+
if (!ok) return res.status(404).json({ error: "conversation not found" });
|
|
61
|
+
res.json({ ok: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
54
64
|
// ---- Super-agent channel threads ----
|
|
55
65
|
// The super-agent's chats (telegram, web quick-chat, desktop, deck …) live
|
|
56
66
|
// in the global per-channel ledger, not in per-agent conversation files.
|
|
@@ -73,6 +83,14 @@ export function register(app, { project, config }) {
|
|
|
73
83
|
res.json(thread);
|
|
74
84
|
});
|
|
75
85
|
|
|
86
|
+
app.delete("/projects/:pid/super-agent/threads/:channel/:id", (req, res) => {
|
|
87
|
+
const p = project(req, res);
|
|
88
|
+
if (!p) return;
|
|
89
|
+
const ok = deleteGlobalThread({ channel: req.params.channel, date: req.params.id });
|
|
90
|
+
if (!ok) return res.status(404).json({ error: "thread not found" });
|
|
91
|
+
res.json({ ok: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
76
94
|
async function handleCompact(req, res, filename) {
|
|
77
95
|
const p = project(req, res);
|
|
78
96
|
if (!p) return;
|