project manager upgrades Phases 1-4

This commit is contained in:
Rob Colbert 2026-05-16 10:48:42 -04:00
parent cc7cdee60b
commit 72102e7fdb
13 changed files with 915 additions and 12 deletions

View File

@ -222,6 +222,25 @@ export interface AuthResponse {
token: string;
}
export interface ProjectSkill {
_id: string;
name: string;
content: string;
modes: string[];
}
export interface ProjectTask {
_id: string;
name: string;
provider: string;
selectedModel: string;
mode: string;
crontab: string;
content: string;
enabled: boolean;
lastRun?: string;
}
export interface Project {
_id: string;
createdAt: string;
@ -230,6 +249,10 @@ export interface Project {
name: string;
slug: string;
gitUrl?: string;
description?: string;
system?: string;
skills: ProjectSkill[];
tasks: ProjectTask[];
}
export const userApi = {
@ -255,6 +278,10 @@ export const projectApi = {
slug: string;
gitUrl: string;
status: string;
description: string;
system: string;
skills: ProjectSkill[];
tasks: ProjectTask[];
}>,
) => api.put<Project>(`/api/v1/projects/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import slug from "slug";
import type { User, Project } from "../lib/api";
import type { User, Project, ProjectSkill, ProjectTask } from "../lib/api";
import {
projectApi,
droneApi,
@ -195,21 +195,118 @@ function PullProjectForm({
interface EditProjectFormProps {
project: Project;
providers: AiProvider[];
onCancel: () => void;
onSuccess: () => void;
}
const MODE_OPTIONS = [
{ value: "plan", label: "Plan", color: "bg-blue-900/50 text-blue-300" },
{ value: "build", label: "Build", color: "bg-green-900/50 text-green-300" },
{ value: "test", label: "Test", color: "bg-yellow-900/50 text-yellow-300" },
{ value: "ship", label: "Ship", color: "bg-purple-900/50 text-purple-300" },
{ value: "dev", label: "Dev", color: "bg-red-900/50 text-red-300" },
];
function EditProjectForm({
project,
providers,
onCancel,
onSuccess,
}: EditProjectFormProps) {
const [name, setName] = useState(project.name);
const [slugValue, setSlugValue] = useState(project.slug);
const [gitUrl, setGitUrl] = useState(project.gitUrl || "");
const [description, setDescription] = useState(project.description || "");
const [system, setSystem] = useState(project.system || "");
const [skills, setSkills] = useState<ProjectSkill[]>(project.skills || []);
const [tasks, setTasks] = useState<ProjectTask[]>(project.tasks || []);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
// Track which skills/tasks are expanded
const [expandedSkills, setExpandedSkills] = useState<Set<string>>(new Set());
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const toggleSkillExpanded = (id: string) => {
setExpandedSkills((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleTaskExpanded = (id: string) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const addSkill = () => {
const newSkill: ProjectSkill = {
_id: crypto.randomUUID(),
name: "",
content: "",
modes: [],
};
setSkills([...skills, newSkill]);
setExpandedSkills((prev) => new Set(prev).add(newSkill._id));
};
const removeSkill = (id: string) => {
setSkills(skills.filter((s) => s._id !== id));
setExpandedSkills((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
};
const updateSkill = (id: string, updates: Partial<ProjectSkill>) => {
setSkills(skills.map((s) => (s._id === id ? { ...s, ...updates } : s)));
};
const toggleSkillMode = (id: string, mode: string) => {
const skill = skills.find((s) => s._id === id);
if (!skill) return;
const modes = skill.modes.includes(mode)
? skill.modes.filter((m) => m !== mode)
: [...skill.modes, mode];
updateSkill(id, { modes });
};
const addTask = () => {
const newTask: ProjectTask = {
_id: crypto.randomUUID(),
name: "",
provider: providers[0]?._id || "",
selectedModel: "",
mode: "build",
crontab: "0 0 * * * *",
content: "",
enabled: true,
};
setTasks([...tasks, newTask]);
setExpandedTasks((prev) => new Set(prev).add(newTask._id));
};
const removeTask = (id: string) => {
setTasks(tasks.filter((t) => t._id !== id));
setExpandedTasks((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
};
const updateTask = (id: string, updates: Partial<ProjectTask>) => {
setTasks(tasks.map((t) => (t._id === id ? { ...t, ...updates } : t)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !slugValue) {
@ -225,6 +322,10 @@ function EditProjectForm({
name,
slug: slug(slugValue),
gitUrl: gitUrl || undefined,
description: description || undefined,
system: system || undefined,
skills: skills.filter((s) => s.name.trim()), // only send skills with names
tasks: tasks.filter((t) => t.name.trim()), // only send tasks with names
});
onSuccess();
} catch (err) {
@ -234,10 +335,14 @@ function EditProjectForm({
}
};
const inputClass =
"w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none";
return (
<div className="max-w-lg">
<div className="max-w-2xl">
<h2 className="text-xl font-semibold mb-6">Edit Project</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Basic Fields */}
<div>
<label className="block text-sm text-text-secondary mb-1">
Project Name *
@ -246,12 +351,9 @@ function EditProjectForm({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
className={inputClass}
placeholder="My Project"
/>
<p className="text-xs text-text-muted mt-1">
Used for display purposes only
</p>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">
@ -261,7 +363,7 @@ function EditProjectForm({
type="text"
value={slugValue}
onChange={(e) => setSlugValue(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
className={inputClass}
placeholder="my-project"
/>
<p className="text-xs text-text-muted mt-1">
@ -277,10 +379,431 @@ function EditProjectForm({
type="text"
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
className={inputClass}
placeholder="https://github.com/user/repo.git"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm text-text-secondary mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className={`${inputClass} resize-y`}
rows={3}
placeholder="Brief description of the project"
/>
</div>
{/* System Prompt */}
<div>
<label className="block text-sm text-text-secondary mb-1">
System Prompt
</label>
<textarea
value={system}
onChange={(e) => setSystem(e.target.value)}
className={`${inputClass} resize-y font-mono text-sm`}
rows={6}
placeholder="Project-specific instructions appended to the agent's system prompt..."
/>
<p className="text-xs text-text-muted mt-1">
Appended to the agent's system prompt when working on this project.
Use this to provide project-specific instructions, such as how to
manage API servers as subprocesses during development.
</p>
</div>
{/* Skills Editor */}
<div className="border border-border-default rounded">
<div className="flex items-center justify-between p-3 border-b border-border-subtle">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Skills ({skills.length})
</h3>
<button
type="button"
onClick={addSkill}
className="px-3 py-1 text-xs border border-border-default text-text-secondary rounded hover:bg-bg-tertiary hover:text-text-primary transition-colors"
>
+ Add Skill
</button>
</div>
{skills.length === 0 ? (
<div className="p-3 text-sm text-text-muted">
No skills defined. Skills provide project-specific knowledge that
agents can discover using the <code className="text-text-secondary">list_skills()</code> and{" "}
<code className="text-text-secondary">read_skill()</code> tools.
</div>
) : (
<div className="divide-y divide-border-subtle">
{skills.map((skill) => {
const isExpanded = expandedSkills.has(skill._id);
return (
<div key={skill._id}>
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-bg-tertiary/50 transition-colors"
onClick={() => toggleSkillExpanded(skill._id)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-text-muted text-xs">
{isExpanded ? "▼" : "▶"}
</span>
<span className="text-sm text-text-primary truncate">
{skill.name || "Unnamed Skill"}
</span>
<div className="flex gap-1">
{skill.modes.map((mode) => {
const opt = MODE_OPTIONS.find(
(m) => m.value === mode,
);
return opt ? (
<span
key={mode}
className={`px-1.5 py-0.5 text-[10px] rounded ${opt.color}`}
>
{opt.label}
</span>
) : null;
})}
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeSkill(skill._id);
}}
className="p-1 text-text-muted hover:text-red-400 transition-colors"
title="Remove skill"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{isExpanded && (
<div className="p-3 pt-0 space-y-3">
<div>
<label className="block text-xs text-text-muted mb-1">
Name
</label>
<input
type="text"
value={skill.name}
onChange={(e) =>
updateSkill(skill._id, {
name: e.target.value,
})
}
className={inputClass}
placeholder="Skill name"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">
Content
</label>
<textarea
value={skill.content}
onChange={(e) =>
updateSkill(skill._id, {
content: e.target.value,
})
}
className={`${inputClass} resize-y font-mono text-sm`}
rows={6}
placeholder="Skill instructions and knowledge..."
/>
<p className="text-xs text-text-muted mt-1">
What the agent reads via the{" "}
<code>read_skill()</code> tool
</p>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">
Available Modes
</label>
<div className="flex flex-wrap gap-2">
{MODE_OPTIONS.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-1.5 cursor-pointer"
>
<input
type="checkbox"
checked={skill.modes.includes(opt.value)}
onChange={() =>
toggleSkillMode(skill._id, opt.value)
}
className="rounded border-border-default bg-bg-tertiary"
/>
<span
className={`px-1.5 py-0.5 text-xs rounded ${opt.color}`}
>
{opt.label}
</span>
</label>
))}
</div>
<p className="text-xs text-text-muted mt-1">
Which modes can see this skill via{" "}
<code>list_skills()</code>
</p>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Tasks Editor */}
<div className="border border-border-default rounded">
<div className="flex items-center justify-between p-3 border-b border-border-subtle">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Tasks ({tasks.length})
</h3>
<button
type="button"
onClick={addTask}
className="px-3 py-1 text-xs border border-border-default text-text-secondary rounded hover:bg-bg-tertiary hover:text-text-primary transition-colors"
>
+ Add Task
</button>
</div>
{tasks.length === 0 ? (
<div className="p-3 text-sm text-text-muted">
No tasks defined. Tasks are scheduled agent runs with a crontab
schedule.
</div>
) : (
<div className="divide-y divide-border-subtle">
{tasks.map((task) => {
const isExpanded = expandedTasks.has(task._id);
const taskProvider = providers.find(
(p) => p._id === task.provider,
);
return (
<div key={task._id}>
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-bg-tertiary/50 transition-colors"
onClick={() => toggleTaskExpanded(task._id)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-text-muted text-xs">
{isExpanded ? "▼" : "▶"}
</span>
<span className="text-sm text-text-primary truncate">
{task.name || "Unnamed Task"}
</span>
{task.enabled ? (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-green-900/50 text-green-300">
Enabled
</span>
) : (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-900/50 text-gray-400">
Disabled
</span>
)}
{task.crontab && (
<span className="font-mono text-[10px] text-text-muted">
{task.crontab}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTask(task._id);
}}
className="p-1 text-text-muted hover:text-red-400 transition-colors"
title="Remove task"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{isExpanded && (
<div className="p-3 pt-0 space-y-3">
<div>
<label className="block text-xs text-text-muted mb-1">
Name
</label>
<input
type="text"
value={task.name}
onChange={(e) =>
updateTask(task._id, {
name: e.target.value,
})
}
className={inputClass}
placeholder="Task name"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-text-muted mb-1">
Provider
</label>
<select
value={task.provider}
onChange={(e) => {
updateTask(task._id, {
provider: e.target.value,
selectedModel: "",
});
}}
className={inputClass}
>
<option value="">Select provider</option>
{providers.map((p) => (
<option key={p._id} value={p._id}>
{p.name} ({p.apiType})
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">
Model
</label>
<select
value={task.selectedModel}
onChange={(e) =>
updateTask(task._id, {
selectedModel: e.target.value,
})
}
className={inputClass}
disabled={!taskProvider}
>
<option value="">Select model</option>
{taskProvider?.models
?.sort((a, b) =>
a.name.localeCompare(b.name),
)
.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
{model.parameterLabel
? ` (${model.parameterLabel})`
: ""}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-text-muted mb-1">
Mode
</label>
<select
value={task.mode}
onChange={(e) =>
updateTask(task._id, {
mode: e.target.value,
})
}
className={inputClass}
>
<option value="plan">Plan</option>
<option value="build">Build</option>
<option value="test">Test</option>
<option value="ship">Ship</option>
<option value="dev">Dev</option>
</select>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">
Schedule
</label>
<input
type="text"
value={task.crontab}
onChange={(e) =>
updateTask(task._id, {
crontab: e.target.value,
})
}
className={`${inputClass} font-mono text-sm`}
placeholder="0 0 * * * *"
/>
<p className="text-xs text-text-muted mt-1">
6-field cron: sec min hour day month weekday
</p>
</div>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">
Prompt
</label>
<textarea
value={task.content}
onChange={(e) =>
updateTask(task._id, {
content: e.target.value,
})
}
className={`${inputClass} resize-y font-mono text-sm`}
rows={4}
placeholder="Task prompt for the agent..."
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={task.enabled}
onChange={(e) =>
updateTask(task._id, {
enabled: e.target.checked,
})
}
className="rounded border-border-default bg-bg-tertiary"
/>
<span className="text-sm text-text-secondary">
Enabled
</span>
</label>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex gap-3 pt-2">
<button
@ -305,12 +828,14 @@ function EditProjectForm({
interface ProjectInspectorProps {
project: Project;
providers: AiProvider[];
onDelete: () => void;
onUpdate: () => void;
}
function ProjectInspector({
project,
providers,
onDelete,
onUpdate,
}: ProjectInspectorProps) {
@ -336,6 +861,15 @@ function ProjectInspector({
}
};
const modeBadge = (mode: string) => {
const opt = MODE_OPTIONS.find((m) => m.value === mode);
return opt ? (
<span className={`px-1.5 py-0.5 text-[10px] rounded ${opt.color}`}>
{opt.label}
</span>
) : null;
};
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl">
@ -343,6 +877,7 @@ function ProjectInspector({
{editing ? (
<EditProjectForm
project={project}
providers={providers}
onCancel={() => setEditing(false)}
onSuccess={() => {
setEditing(false);
@ -351,7 +886,7 @@ function ProjectInspector({
/>
) : (
<>
<div className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Name</div>
@ -367,6 +902,16 @@ function ProjectInspector({
</div>
</div>
{/* Description */}
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Description</div>
<div className="text-text-primary text-sm">
{project.description || (
<span className="text-text-muted">Not configured</span>
)}
</div>
</div>
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Git URL</div>
<div className="text-text-primary font-mono text-sm break-all">
@ -376,6 +921,75 @@ function ProjectInspector({
</div>
</div>
{/* System Prompt */}
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">System Prompt</div>
<div className="text-text-primary text-sm font-mono">
{project.system ? (
project.system.length > 200 ? (
<span>{project.system.slice(0, 200)}...</span>
) : (
project.system
)
) : (
<span className="text-text-muted font-sans">Not configured</span>
)}
</div>
</div>
{/* Skills */}
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-2">Skills ({project.skills?.length || 0})</div>
{project.skills && project.skills.length > 0 ? (
<div className="space-y-1.5">
{project.skills.map((skill) => (
<div
key={skill._id}
className="flex items-center gap-2 text-sm"
>
<span className="text-text-primary">{skill.name}</span>
<div className="flex gap-1">
{skill.modes.map((mode) => modeBadge(mode))}
</div>
</div>
))}
</div>
) : (
<span className="text-text-muted text-sm">No skills defined</span>
)}
</div>
{/* Tasks */}
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-2">Tasks ({project.tasks?.length || 0})</div>
{project.tasks && project.tasks.length > 0 ? (
<div className="space-y-1.5">
{project.tasks.map((task) => (
<div
key={task._id}
className="flex items-center gap-2 text-sm"
>
<span className="text-text-primary">{task.name}</span>
{task.enabled ? (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-green-900/50 text-green-300">
Enabled
</span>
) : (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-900/50 text-gray-400">
Disabled
</span>
)}
<span className="font-mono text-[10px] text-text-muted">
{task.crontab}
</span>
</div>
))}
</div>
) : (
<span className="text-text-muted text-sm">No tasks defined</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Status</div>
@ -895,12 +1509,14 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
null,
);
const [providers, setProviders] = useState<AiProvider[]>([]);
const [loading, setLoading] = useState(true);
const [showNewForm, setShowNewForm] = useState(false);
const [showPullForm, setShowPullForm] = useState(false);
useEffect(() => {
loadProjects();
loadProviders();
}, []);
useEffect(() => {
@ -923,6 +1539,15 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
}
};
const loadProviders = async () => {
try {
const data = await providerApi.getAll();
setProviders(data.sort((a, b) => a.name.localeCompare(b.name)));
} catch (err) {
console.error("Failed to load providers", err);
}
};
const handleSelectProject = (project: Project) => {
navigate(`/projects/${project.slug}`);
};
@ -1084,6 +1709,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
{/* Center - Project Inspector */}
<ProjectInspector
project={selectedProject}
providers={providers}
onDelete={handleProjectDeleted}
onUpdate={handleProjectUpdated}
/>

View File

@ -196,12 +196,16 @@ export class ProjectApiControllerV1 extends DtpController {
return;
}
const { name, slug, gitUrl, status } = req.body;
const { name, slug, gitUrl, status, description, system, skills, tasks } = req.body;
const updated = await projectService.update(project, {
name,
slug,
gitUrl,
status: status as ProjectStatus,
description,
system,
skills,
tasks,
});
res.status(200).json({

View File

@ -4,9 +4,33 @@
import { Schema, model } from "mongoose";
import { ProjectStatus, IProject } from "@gadget/api";
import {
ProjectStatus,
IProject,
IProjectSkill,
ChatSessionMode,
IProjectTask,
Types,
} from "@gadget/api";
import { nanoid } from "nanoid";
export const ProjectSkillSchema = new Schema<IProjectSkill>({
name: { type: String, required: true },
modes: { type: [String], enum: ChatSessionMode, required: true },
content: { type: String, required: true },
});
export const ProjectTaskSchema = new Schema<IProjectTask>({
name: { type: String, required: true },
provider: { type: Types.ObjectId, required: true, ref: "AiProvider" },
selectedModel: { type: String, required: true },
mode: { type: String, enum: ChatSessionMode, required: true },
crontab: { type: String, required: true },
content: { type: String, required: true },
enabled: { type: Boolean, default: true, required: true },
lastRun: { type: Types.ObjectId, ref: "ChatSession" },
});
export const ProjectSchema = new Schema<IProject>({
_id: { type: String, default: () => nanoid() },
createdAt: { type: Date, default: Date.now, required: true },
@ -20,6 +44,10 @@ export const ProjectSchema = new Schema<IProject>({
required: true,
},
gitUrl: { type: String },
description: { type: String },
system: { type: String },
skills: { type: [ProjectSkillSchema], default: [], required: true },
tasks: { type: [ProjectTaskSchema], default: [], required: true },
});
ProjectSchema.index(

View File

@ -381,6 +381,15 @@ class ChatSessionService extends DtpService {
.replace("{{session_block}}", sessionBlock)
.replace("{{persona_block}}", personaBlock);
/*
* Project System Prompt appended as an appendix when the project
* has a `system` field configured. This allows project-specific
* instructions to be injected without modifying mode templates.
*/
if (project.system && project.system.trim().length > 0) {
prompt += `\n\n## PROJECT CONFIGURATION\n\n### System Instructions\n${project.system.trim()}`;
}
return prompt;
}

View File

@ -6,7 +6,7 @@ import slug from "slug";
import { MongooseBaseQueryOptions, PopulateOptions } from "mongoose";
import { IProject, IUser, ProjectStatus } from "@gadget/api";
import { IProject, IProjectSkill, IProjectTask, IUser, ProjectStatus } from "@gadget/api";
import Project from "@/models/project.js";
import { DtpService } from "../lib/service.js";
@ -16,6 +16,10 @@ export interface IProjectDefinition {
slug: string;
gitUrl?: string;
status?: ProjectStatus;
description?: string;
system?: string;
skills?: IProjectSkill[];
tasks?: IProjectTask[];
}
class ProjectService extends DtpService {
@ -158,10 +162,41 @@ class ProjectService extends DtpService {
if (definition.gitUrl !== project.gitUrl) {
update.$set.gitUrl = definition.gitUrl;
}
} else if (definition.gitUrl === undefined && definition.hasOwnProperty('gitUrl')) {
// Only unset if explicitly provided as undefined/empty
} else {
update.$unset.gitUrl = 1;
}
// Description — set or unset
if (definition.description !== undefined) {
if (definition.description !== project.description) {
update.$set.description = definition.description;
}
}
// System prompt — set or unset
if (definition.system !== undefined) {
if (definition.system !== project.system) {
update.$set.system = definition.system;
}
}
// Skills — full array replacement
if (definition.skills !== undefined) {
update.$set.skills = definition.skills;
}
// Tasks — full array replacement
if (definition.tasks !== undefined) {
update.$set.tasks = definition.tasks;
}
// Clean up empty $unset to avoid MongoDB errors
if (Object.keys(update.$unset as Record<string, unknown>).length === 0) {
delete (update as any).$unset;
}
const newProject = await Project.findOneAndUpdate(
{ _id: project._id },
update,

View File

@ -52,6 +52,8 @@ import {
ShellExecTool,
SubagentTool,
SubprocessTool,
ListSkillsTool,
ReadSkillTool,
type DroneToolboxEnvironment,
} from "../tools/index.ts";
@ -131,6 +133,10 @@ class AgentService extends GadgetService {
subagentTool.setSpawner((agentType, prompt) => this.spawnSubagent(agentType, prompt));
this.toolbox.register(subagentTool, readOnlyModes);
// Project tools — skill discovery: available in all modes
this.toolbox.register(new ListSkillsTool(this.toolbox), readOnlyModes);
this.toolbox.register(new ReadSkillTool(this.toolbox), readOnlyModes);
this.log.info("started");
}
@ -548,6 +554,8 @@ class AgentService extends GadgetService {
projectDir: WorkspaceService.getProjectDirectory(project.slug),
cacheDir,
});
this.toolbox.updateProjectContext(project, turn.mode);
}
async spawnSubagent(

View File

@ -7,3 +7,4 @@ export * from "./chat/index.ts";
export * from "./system/index.ts";
export * from "./network/index.ts";
export * from "./plan/index.ts";
export * from "./project/index.ts";

View File

@ -0,0 +1,5 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
export { ListSkillsTool } from "./list-skills.ts";
export { ReadSkillTool } from "./read-skill.ts";

View File

@ -0,0 +1,62 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import type { IAiLogger, IToolArguments } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
export class ListSkillsTool extends DroneTool {
get name(): string {
return "list_skills";
}
get category(): string {
return "project";
}
public definition = {
type: "function" as const,
function: {
name: this.name,
description:
"List all skills defined on the current project that are available for the current mode. Skills contain project-specific knowledge and instructions that can help you work more effectively. Use read_skill(index) to read a skill's full content.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
};
public async execute(_args: IToolArguments, _logger: IAiLogger): Promise<string> {
const project = this.toolbox.env.project;
if (!project) {
return "No project is configured for this session.";
}
const mode = this.toolbox.env.chatSessionMode;
const skills = project.skills.filter(
(s) => mode && s.modes.includes(mode),
);
if (skills.length === 0) {
return mode
? `Project Skills (0 available in ${mode} mode)\n\nNo skills are defined for this project in ${mode} mode.`
: "No skills are defined for this project.";
}
const lines: string[] = [
`Project Skills (${skills.length} available in ${mode} mode):`,
"",
];
for (let i = 0; i < skills.length; i++) {
const skill = skills[i];
const preview = skill.content.length > 80
? skill.content.slice(0, 80).trim() + "..."
: skill.content.trim();
lines.push(`[#${i}] ${skill.name}${preview}`);
}
return lines.join("\n");
}
}

View File

@ -0,0 +1,64 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import type { IAiLogger, IToolArguments } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
export class ReadSkillTool extends DroneTool {
get name(): string {
return "read_skill";
}
get category(): string {
return "project";
}
public definition = {
type: "function" as const,
function: {
name: this.name,
description:
"Read the full content of a project skill by its index number. Use list_skills() first to see available skills and their indices.",
parameters: {
type: "object",
properties: {
index: {
type: "number",
description: "The skill index from list_skills()",
},
},
required: ["index"],
},
},
};
public async execute(args: IToolArguments, _logger: IAiLogger): Promise<string> {
const project = this.toolbox.env.project;
if (!project) {
return "No project is configured for this session.";
}
const index = typeof args.index === "number" ? args.index : parseInt(String(args.index), 10);
if (isNaN(index)) {
return "Invalid index. Provide a numeric index from list_skills().";
}
const mode = this.toolbox.env.chatSessionMode;
const skills = project.skills.filter(
(s) => mode && s.modes.includes(mode),
);
if (index < 0 || index >= skills.length) {
return `Skill index ${index} not found. Use list_skills() to see available skills.`;
}
const skill = skills[index];
const lines: string[] = [
`Skill: ${skill.name} (${mode} mode)`,
"",
skill.content,
];
return lines.join("\n");
}
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0
import type { IAiTool } from "@gadget/ai";
import type { IProject, ChatSessionMode } from "@gadget/api";
export interface DroneToolboxEnvironment {
NODE_ENV: string;
@ -18,6 +19,8 @@ export interface DroneToolboxEnvironment {
projectDir?: string;
cacheDir?: string;
};
project?: IProject;
chatSessionMode?: ChatSessionMode;
}
export type ToolMap = Map<string, IAiTool>;
@ -40,6 +43,11 @@ export class AiToolbox {
this._env.workspace = workspace;
}
updateProjectContext(project: IProject, mode: ChatSessionMode): void {
this._env.project = project;
this._env.chatSessionMode = mode;
}
register(tool: IAiTool, modes?: string[]): void {
if (this.tools.has(tool.name)) {
throw new Error(`tool already registered: ${tool.name}`);

View File

@ -3,8 +3,11 @@
// Licensed under the Apache License, Version 2.0
import type { IUser } from "./user.js";
import type { IAiProvider } from "./ai-provider.ts";
import { GadgetId } from "../lib/gadget-id.ts";
import { HydratedDocument } from "mongoose";
import { ChatSessionMode, IChatSession } from "./chat-session.ts";
export enum ProjectStatus {
Active = "active",
@ -12,6 +15,25 @@ export enum ProjectStatus {
Archived = "archived",
}
export interface IProjectSkill {
_id: GadgetId;
name: string;
content: string;
modes: ChatSessionMode[];
}
export interface IProjectTask {
_id: GadgetId;
name: string;
provider: IAiProvider | GadgetId;
selectedModel: string;
mode: ChatSessionMode;
crontab: string;
content: string;
enabled: boolean;
lastRun: IChatSession | GadgetId;
}
export interface IProject {
_id: GadgetId;
createdAt: Date;
@ -20,6 +42,10 @@ export interface IProject {
name: string;
slug: string;
gitUrl?: string;
description?: string;
system?: string;
skills: IProjectSkill[];
tasks: IProjectTask[];
}
export type ProjectDocument = HydratedDocument<IProject>;