mirror of
https://github.com/Heretek-AI/GDevelop-Unlocked.git
synced 2026-11-07 10:55:58 -10:00
feat(plugin): add gdevelop-architect agent persona (T10, #17)
This commit is contained in:
1 parent
bc77e107d5
commit
8fa3f43e7e
4 files changed
+347
-3
No files matched your search
@@ -0,0 +1,53 @@
|
||||
# `@heretek-ai/opencode-gdevelop`
|
||||
|
||||
OpenCode plugin for **local, offline** GDevelop 5 project control. Six
|
||||
`gdevelop_*` tools plus a `gdevelop-architect` subagent persona, all running
|
||||
in-process with zero network egress (never contacts `api.gdevelop.io`).
|
||||
|
||||
## Install
|
||||
|
||||
This package is a plain ESM module with **zero dependencies**.
|
||||
`@opencode/plugin` is a host-provided peer API, not a bundled dep: the
|
||||
OpenCode runtime resolves the import at load time. Outside the runtime
|
||||
(tests, CI) the entry falls back to an identity `Plugin.define`
|
||||
passthrough so the `{ id, setup }` definition stays importable.
|
||||
|
||||
Point your OpenCode config at this directory as a local plugin; the entry
|
||||
is `packages/opencode-gdevelop/index.mjs` (plugin id: `gdevelop`).
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it does |
|
||||
| --- | --- |
|
||||
| `gdevelop_project_read` | Inspect scenes/objects/behaviors/variables from a project on disk (optional `sceneName` narrows to one scene). |
|
||||
| `gdevelop_project_create_scene` | Create a scene (layout); fails closed on duplicates/blank names. |
|
||||
| `gdevelop_project_create_object` | Create/replace an object: `Sprite`, `TextObject::Text`, `PanelSpriteObject::PanelSprite`, `Scene3D::*`, or any engine type; scene-scoped or global. |
|
||||
| `gdevelop_project_add_behavior` | Attach a behavior (e.g. `PlatformBehavior::PlatformerObjectBehavior`) with family defaults. |
|
||||
| `gdevelop_project_add_events` | Append a `BuiltinCommonInstructions::Standard` event (`conditions[]`/`actions[]`) to a scene sheet. |
|
||||
| `gdevelop_cli_run` | Format a headless CLI dispatch (`--run-command UPPER_SNAKE_CASE`, repeatable `--cmd-args`, `--disable-update-check`); the host spawns it. |
|
||||
|
||||
Every mutation delegates to
|
||||
`packages/unlock-shim/control-shim.mjs` (`readGDevelopProject`,
|
||||
`createScene`, `createOrReplaceObject`, `addBehavior`, `addStandardEvent`,
|
||||
`buildGDevelopCliCommand`, `writeGDevelopProject`) — no reimplemented
|
||||
project logic. Inputs are plain JSON-Schema objects; each `execute` returns
|
||||
a host-shaped `{ output, content }` result and rejects fail-closed with the
|
||||
path/id named.
|
||||
|
||||
## Agent: `gdevelop-architect`
|
||||
|
||||
Registered via `ctx.agent.transform(editor => editor.update(...))` (host
|
||||
upsert semantics). Subagent-mode persona whose system prompt covers GDevelop
|
||||
5 architecture, the GDJS lifecycle (game loop, scene stack), event
|
||||
conditions/actions/sub-events, and asset management. Permissions: every
|
||||
`gdevelop_*` tool (derived from `TOOL_NAMES`, so they cannot drift) plus
|
||||
host file `read`/`write`.
|
||||
|
||||
## Offline scope
|
||||
|
||||
- No `dependencies`/`devDependencies`; only `node:` builtins + the control shim.
|
||||
- No `api.gdevelop.io` references anywhere in the package.
|
||||
- Tests (`tests/integration/opencode-plugin-tools.test.mjs`,
|
||||
`tests/integration/opencode-plugin-agent.test.mjs`) use in-process ctx
|
||||
doubles with real registries and real scratch-project I/O under
|
||||
`os.tmpdir()` — no mocks of the code under test.
|
||||
@@ -0,0 +1,76 @@
|
||||
// GDevelop architect agent persona (T10, issue #17).
|
||||
//
|
||||
// Registers the `gdevelop-architect` subagent through the host
|
||||
// `ctx.agent.transform(editor => editor.update(id, fn))` API. The editor's
|
||||
// `update` upserts (review/opencode/packages/core/src/agent.ts: a missing id
|
||||
// starts from Info.default(id)), so this works as a pure registration with
|
||||
// no pre-existing agent entry required.
|
||||
//
|
||||
// Tool permissions derive from TOOL_NAMES (tools.mjs) so the architect can
|
||||
// always call every gdevelop_* tool, plus host file read/write.
|
||||
|
||||
import { TOOL_NAMES } from './tools.mjs';
|
||||
|
||||
/** Stable host agent id. */
|
||||
export const ARCHITECT_ID = 'gdevelop-architect';
|
||||
|
||||
const SYSTEM_PROMPT = `You are the GDevelop 5 architect: a specialist in designing, building, and debugging local GDevelop game projects (GDJS runtime, offline).
|
||||
|
||||
GDevelop 5 architecture
|
||||
- A project (game.json, single-file or split-folder) holds properties, global objects/variables, resources, layouts (scenes), and external event sheets.
|
||||
- A scene holds layers (with cameras and effects), instances placed on layers, scene objects, scene variables, behaviors on objects, and one event sheet.
|
||||
- Objects carry a type (Sprite, TextObject::Text, PanelSpriteObject::PanelSprite, Scene3D::* 3D boxes, …), variables, effects, and behaviors. Prefer scene objects; use global objects only for cross-scene sharing.
|
||||
- Behaviors attach capabilities (PlatformerObject, Physics2, TopDownMovement, …) with per-family default parameters; name each behavior instance explicitly.
|
||||
|
||||
GDJS lifecycle
|
||||
- The engine runs a game loop: input → events evaluation → behaviors stepping → rendering, once per frame per scene on the scene stack.
|
||||
- Scene stack: scenes push/replace/pause; paused scenes keep state. Startup scene is the project's first layout unless changed.
|
||||
- Hot-reload and preview inspection go through the debugger bridge; headless builds/exports go through the CLI dispatcher (--run-command UPPER_SNAKE_CASE with --cmd-args).
|
||||
|
||||
Events: conditions, actions, sub-events
|
||||
- An event sheet is an ordered list of BuiltinCommonInstructions::Standard events, each with conditions[] and actions[].
|
||||
- Conditions are all AND-ed (all must hold) unless marked OR; an empty condition list means "always".
|
||||
- Actions run top-to-bottom when conditions hold; order actions so state reads precede state writes within the same frame.
|
||||
- Sub-events run only when the parent event's conditions held; use them to narrow scope instead of duplicating parent conditions.
|
||||
- Keep sheets small and layered: input events, then simulation, then camera/HUD updates. Never emit circular include cycles between external sheets.
|
||||
|
||||
Asset management
|
||||
- Every texture, audio file, and font must be declared in the project's resources with a stable name before events or objects reference it.
|
||||
- Prefer sprite sheets / atlases over loose frames; keep audio in engine-supported formats and preload scene-critical assets.
|
||||
- Reference assets by resource name, never by absolute disk path, so projects stay portable across machines and CI.
|
||||
|
||||
Working rules
|
||||
- Inspect first (gdevelop_project_read), then mutate in dependency order: scenes → objects → behaviors → events.
|
||||
- Use the gdevelop_* tools for every project mutation; use host file read/write only for supporting files (docs, configs), never to hand-edit game.json.
|
||||
- Format CLI work with gdevelop_cli_run and let the host spawn it; never invent command names outside UPPER_SNAKE_CASE.
|
||||
- Report what changed per scene/object/event so the user can verify in the editor.`;
|
||||
|
||||
const allow = action => ({ action, resource: '*', effect: 'allow' });
|
||||
|
||||
/** Canonical architect config (plain data; also applied via transform). */
|
||||
export const GDEVELOP_ARCHITECT = {
|
||||
id: ARCHITECT_ID,
|
||||
name: 'GDevelop Architect',
|
||||
description:
|
||||
'Designs and mutates local GDevelop 5 projects: scenes, objects, behaviors, event sheets, assets, and headless CLI dispatches.',
|
||||
system: SYSTEM_PROMPT,
|
||||
mode: 'subagent',
|
||||
permissions: [
|
||||
...TOOL_NAMES.map(allow),
|
||||
allow('read'),
|
||||
allow('write'),
|
||||
],
|
||||
};
|
||||
|
||||
/** Register (upsert) the architect persona on a host ctx. */
|
||||
export async function registerGDevelopAgents(ctx) {
|
||||
await ctx.agent.transform(editor => {
|
||||
editor.update(ARCHITECT_ID, agent => {
|
||||
agent.name = GDEVELOP_ARCHITECT.name;
|
||||
agent.description = GDEVELOP_ARCHITECT.description;
|
||||
agent.system = GDEVELOP_ARCHITECT.system;
|
||||
agent.mode = GDEVELOP_ARCHITECT.mode;
|
||||
agent.permissions = GDEVELOP_ARCHITECT.permissions.map(rule => ({ ...rule }));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
// GDevelop OpenCode plugin entry (T09, issue #17).
|
||||
// GDevelop OpenCode plugin entry (T09 tools + T10 architect, issue #17).
|
||||
//
|
||||
// Registers the six gdevelop_* project-control tools through the host
|
||||
// `ctx.tool.transform(editor => editor.add(tool))` API (Promise plugin API:
|
||||
// `ctx.tool.transform(editor => editor.add(tool))` API and the
|
||||
// gdevelop-architect persona through
|
||||
// `ctx.agent.transform(editor => editor.update(id, fn))` (Promise plugin API:
|
||||
// review/opencode/packages/plugin/src/README.md; ToolEditor.add in
|
||||
// review/opencode/packages/plugin/src/promise/tool.ts).
|
||||
// review/opencode/packages/plugin/src/promise/tool.ts; agent upsert in
|
||||
// review/opencode/packages/core/src/agent.ts).
|
||||
//
|
||||
// `@opencode/plugin` is a host-provided peer API, NOT a bundled dependency
|
||||
// (this package ships zero deps): the host resolves the import at load time,
|
||||
// while offline runs (tests, CI) fall back to the identity passthrough below.
|
||||
|
||||
import { GDEVELOP_TOOLS } from './tools.mjs';
|
||||
import { registerGDevelopAgents } from './agents.mjs';
|
||||
|
||||
/** Raw plugin definition; also exported for offline/test harnesses. */
|
||||
export const definition = {
|
||||
@@ -18,6 +22,7 @@ export const definition = {
|
||||
await ctx.tool.transform(editor => {
|
||||
for (const tool of GDEVELOP_TOOLS) editor.add(tool);
|
||||
});
|
||||
await registerGDevelopAgents(ctx);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Integration Test: gdevelop-architect agent persona (T10, issue #17).
|
||||
//
|
||||
// Drives the real packages/opencode-gdevelop/ plugin setup through an
|
||||
// in-process ctx double whose agent editor mirrors the host upsert
|
||||
// semantics (review/opencode/packages/core/src/agent.ts: update() creates
|
||||
// Info.default(id) when the id is absent), then confirms
|
||||
// ctx.agent.get("gdevelop-architect") returns a valid config with real
|
||||
// permission lists. Tool executors are NOT re-exercised here (covered by
|
||||
// opencode-plugin-tools.test.mjs); this file asserts registration,
|
||||
// persona content, permissions, packaging docs, and idempotency.
|
||||
//
|
||||
// Bare node:test + builtins only. No skipped tests.
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { definition as pluginDefinition } from '../../packages/opencode-gdevelop/index.mjs';
|
||||
import {
|
||||
ARCHITECT_ID,
|
||||
GDEVELOP_ARCHITECT,
|
||||
registerGDevelopAgents,
|
||||
} from '../../packages/opencode-gdevelop/agents.mjs';
|
||||
import { TOOL_NAMES } from '../../packages/opencode-gdevelop/tools.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..', '..');
|
||||
|
||||
// Host-faithful agent editor double: update() upserts like core agent.ts.
|
||||
const createAgentEditorDouble = (seed = []) => {
|
||||
const agents = new Map(seed.map(a => [a.id, { ...a }]));
|
||||
return {
|
||||
agents,
|
||||
list: () => [...agents.values()],
|
||||
get: id => agents.get(id),
|
||||
default: () => {},
|
||||
update: (id, fn) => {
|
||||
const current = agents.get(id) || { id };
|
||||
if (!agents.has(id)) agents.set(id, current);
|
||||
fn(current);
|
||||
current.id = id;
|
||||
},
|
||||
remove: id => {
|
||||
agents.delete(id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createCtxDouble = (seedAgents = []) => {
|
||||
const agentEditor = createAgentEditorDouble(seedAgents);
|
||||
const tools = new Map();
|
||||
return {
|
||||
agentEditor,
|
||||
agent: {
|
||||
transform: async fn => {
|
||||
fn(agentEditor);
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
get: async id => agentEditor.get(id),
|
||||
list: async () => agentEditor.list(),
|
||||
},
|
||||
tool: {
|
||||
transform: async fn => {
|
||||
fn({
|
||||
list: () => [...tools.values()],
|
||||
get: id => tools.get(id),
|
||||
namespace: () => {},
|
||||
add: tool => {
|
||||
tools.set(tool.name, { ...tool, id: tool.name });
|
||||
},
|
||||
update: () => {},
|
||||
remove: id => {
|
||||
tools.delete(id);
|
||||
},
|
||||
});
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('integration: gdevelop-architect registration (real setup)', () => {
|
||||
let ctx;
|
||||
beforeEach(async () => {
|
||||
ctx = createCtxDouble();
|
||||
await pluginDefinition.setup(ctx);
|
||||
});
|
||||
|
||||
it('registers the architect via ctx.agent.transform (upsert, id gdevelop-architect)', async () => {
|
||||
assert.equal(ARCHITECT_ID, 'gdevelop-architect');
|
||||
const agent = await ctx.agent.get('gdevelop-architect');
|
||||
assert.ok(agent, 'architect present after setup');
|
||||
assert.equal(agent.id, 'gdevelop-architect');
|
||||
});
|
||||
|
||||
it('ctx.agent.get returns a valid config: subagent mode, named, described', async () => {
|
||||
const agent = await ctx.agent.get('gdevelop-architect');
|
||||
assert.equal(agent.mode, 'subagent');
|
||||
assert.ok(typeof agent.name === 'string' && agent.name.length > 0);
|
||||
assert.ok(typeof agent.description === 'string' && agent.description.length > 0);
|
||||
assert.deepEqual(
|
||||
{ id: agent.id, mode: agent.mode },
|
||||
{ id: GDEVELOP_ARCHITECT.id, mode: GDEVELOP_ARCHITECT.mode }
|
||||
);
|
||||
});
|
||||
|
||||
it('system prompt covers GDevelop 5 architecture, GDJS lifecycle, events, assets', async () => {
|
||||
const agent = await ctx.agent.get('gdevelop-architect');
|
||||
assert.ok(typeof agent.system === 'string' && agent.system.length > 200);
|
||||
for (const topic of [
|
||||
/GDJS/i,
|
||||
/lifecycle|game loop|scene stack/i,
|
||||
/conditions/i,
|
||||
/actions/i,
|
||||
/sub-events/i,
|
||||
/behavior/i,
|
||||
/asset|resources/i,
|
||||
]) {
|
||||
assert.match(agent.system, topic, `system prompt covers ${topic}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('permission list allows all gdevelop_* tools plus file read/write (real lists)', async () => {
|
||||
const agent = await ctx.agent.get('gdevelop-architect');
|
||||
assert.ok(Array.isArray(agent.permissions) && agent.permissions.length > 0);
|
||||
for (const rule of agent.permissions) {
|
||||
assert.ok(typeof rule.action === 'string');
|
||||
assert.ok(typeof rule.resource === 'string');
|
||||
assert.match(rule.effect, /^(allow|ask|deny)$/);
|
||||
}
|
||||
const allowed = new Set(
|
||||
agent.permissions.filter(r => r.effect === 'allow').map(r => r.action)
|
||||
);
|
||||
for (const tool of TOOL_NAMES) {
|
||||
assert.ok(allowed.has(tool), `architect may call ${tool}`);
|
||||
}
|
||||
assert.ok(allowed.has('read'), 'architect may read files');
|
||||
assert.ok(allowed.has('write'), 'architect may write files');
|
||||
});
|
||||
|
||||
it('setup is idempotent and never clobbers sibling agents', async () => {
|
||||
ctx.agentEditor.set ?? null;
|
||||
const before = await ctx.agent.get('gdevelop-architect');
|
||||
await pluginDefinition.setup(ctx);
|
||||
const after = await ctx.agent.get('gdevelop-architect');
|
||||
assert.deepEqual(after, before, 'second setup converges to the same config');
|
||||
// Sibling agent registered outside the plugin survives a re-setup.
|
||||
ctx.agentEditor.update('build', a => {
|
||||
a.mode = 'primary';
|
||||
a.name = 'Build';
|
||||
});
|
||||
await pluginDefinition.setup(ctx);
|
||||
assert.equal((await ctx.agent.get('build')).name, 'Build');
|
||||
assert.equal((await ctx.agent.get('gdevelop-architect')).mode, 'subagent');
|
||||
});
|
||||
|
||||
it('registerGDevelopAgents is exported standalone and matches setup output', async () => {
|
||||
const solo = createCtxDouble();
|
||||
await registerGDevelopAgents(solo);
|
||||
assert.deepEqual(
|
||||
await solo.agent.get('gdevelop-architect'),
|
||||
await ctx.agent.get('gdevelop-architect')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: gdevelop plugin docs + packaging (T10)', () => {
|
||||
it('README documents tools, agent, and offline use', () => {
|
||||
const readme = fs.readFileSync(
|
||||
path.join(root, 'packages/opencode-gdevelop/README.md'),
|
||||
'utf8'
|
||||
);
|
||||
for (const tool of TOOL_NAMES) {
|
||||
assert.ok(readme.includes(tool), `README documents ${tool}`);
|
||||
}
|
||||
assert.ok(readme.includes('gdevelop-architect'), 'README documents the agent');
|
||||
assert.ok(/offline|zero-egress|zero egress/i.test(readme), 'README states offline scope');
|
||||
});
|
||||
|
||||
it('agent sources stay zero-dep and zero-egress', () => {
|
||||
for (const file of ['agents.mjs', 'index.mjs']) {
|
||||
const src = fs.readFileSync(
|
||||
path.join(root, 'packages/opencode-gdevelop', file),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(!src.includes('api.gdevelop.io'), `${file}: zero egress`);
|
||||
}
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, 'packages/opencode-gdevelop/package.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
assert.equal(pkg.name, '@heretek-ai/opencode-gdevelop');
|
||||
assert.ok(!pkg.dependencies && !pkg.devDependencies, 'still zero deps');
|
||||
});
|
||||
|
||||
it('uses os.tmpdir() scratch semantics (no hardcoded /tmp)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(root, 'tests/integration/opencode-plugin-agent.test.mjs'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(!src.match(/['"]\/tmp\//), 'no hardcoded /tmp paths');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user