palmier 0.6.8 → 0.7.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/README.md +9 -2
- package/dist/agents/agent.d.ts +2 -2
- package/dist/agents/aider.d.ts +1 -1
- package/dist/agents/aider.js +2 -5
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claude.js +2 -5
- package/dist/agents/cline.d.ts +1 -1
- package/dist/agents/cline.js +2 -5
- package/dist/agents/codex.d.ts +1 -1
- package/dist/agents/codex.js +2 -5
- package/dist/agents/copilot.d.ts +1 -1
- package/dist/agents/copilot.js +2 -5
- package/dist/agents/cursor.d.ts +1 -1
- package/dist/agents/cursor.js +2 -5
- package/dist/agents/deepagents.d.ts +1 -1
- package/dist/agents/deepagents.js +2 -5
- package/dist/agents/droid.d.ts +1 -1
- package/dist/agents/droid.js +2 -5
- package/dist/agents/gemini.d.ts +1 -1
- package/dist/agents/gemini.js +2 -5
- package/dist/agents/goose.d.ts +1 -1
- package/dist/agents/goose.js +2 -5
- package/dist/agents/hermes.d.ts +1 -1
- package/dist/agents/hermes.js +2 -5
- package/dist/agents/kimi.d.ts +1 -1
- package/dist/agents/kimi.js +2 -5
- package/dist/agents/kiro.d.ts +1 -1
- package/dist/agents/kiro.js +2 -5
- package/dist/agents/openclaw.d.ts +1 -1
- package/dist/agents/openclaw.js +2 -5
- package/dist/agents/opencode.d.ts +1 -1
- package/dist/agents/opencode.js +2 -5
- package/dist/agents/qoder.d.ts +1 -1
- package/dist/agents/qoder.js +2 -5
- package/dist/agents/qwen.d.ts +1 -1
- package/dist/agents/qwen.js +2 -5
- package/dist/agents/shared-prompt.js +1 -1
- package/dist/commands/run.js +1 -2
- package/dist/commands/serve.js +16 -0
- package/dist/mcp-handler.d.ts +3 -0
- package/dist/mcp-handler.js +59 -3
- package/dist/mcp-tools.d.ts +16 -1
- package/dist/mcp-tools.js +24 -2
- package/dist/notification-store.d.ts +13 -0
- package/dist/notification-store.js +19 -0
- package/dist/pwa/assets/{index-C8vJwUNi.js → index-DLxrL0hR.js} +42 -42
- package/dist/pwa/assets/{web-NxTETXZK.js → web-CBI458eN.js} +1 -1
- package/dist/pwa/assets/{web-6UChJFov.js → web-HDs03L2B.js} +1 -1
- package/dist/pwa/index.html +1 -1
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +27 -67
- package/dist/task.js +2 -3
- package/dist/transports/http-transport.js +51 -3
- package/dist/types.d.ts +0 -1
- package/package.json +2 -2
- package/palmier-server/README.md +1 -1
- package/palmier-server/pwa/src/components/PlanDialog.tsx +5 -12
- package/palmier-server/pwa/src/components/TaskForm.tsx +6 -15
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/types.ts +0 -1
- package/palmier-server/server/src/index.ts +2 -0
- package/palmier-server/server/src/routes/device.ts +32 -0
- package/palmier-server/spec.md +13 -12
- package/src/agents/agent.ts +2 -2
- package/src/agents/aider.ts +2 -5
- package/src/agents/claude.ts +2 -5
- package/src/agents/cline.ts +2 -5
- package/src/agents/codex.ts +2 -5
- package/src/agents/copilot.ts +2 -5
- package/src/agents/cursor.ts +2 -5
- package/src/agents/deepagents.ts +2 -5
- package/src/agents/droid.ts +2 -5
- package/src/agents/gemini.ts +2 -5
- package/src/agents/goose.ts +2 -5
- package/src/agents/hermes.ts +2 -5
- package/src/agents/kimi.ts +2 -5
- package/src/agents/kiro.ts +2 -5
- package/src/agents/openclaw.ts +2 -5
- package/src/agents/opencode.ts +2 -5
- package/src/agents/qoder.ts +2 -5
- package/src/agents/qwen.ts +2 -5
- package/src/agents/shared-prompt.ts +1 -1
- package/src/commands/run.ts +1 -2
- package/src/commands/serve.ts +16 -1
- package/src/mcp-handler.ts +68 -3
- package/src/mcp-tools.ts +48 -2
- package/src/notification-store.ts +30 -0
- package/src/rpc-handler.ts +29 -71
- package/src/task.ts +2 -3
- package/src/transports/http-transport.ts +49 -3
- package/src/types.ts +0 -1
- package/test/agent-instructions.test.ts +117 -19
- package/test/agent-output-parsing.test.ts +1 -0
- package/test/notification-store.test.ts +57 -0
- package/test/task-parsing.test.ts +3 -3
- package/dist/commands/plan-generation.md +0 -22
- package/src/commands/plan-generation.md +0 -22
- package/test/fixtures/agent-instructions-snapshot.md +0 -58
package/src/rpc-handler.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
|
-
import { fileURLToPath } from "url";
|
|
5
4
|
import { spawn, type ChildProcess } from "child_process";
|
|
6
|
-
import { parse as parseYaml } from "yaml";
|
|
7
5
|
import { type NatsConnection } from "nats";
|
|
8
6
|
import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
|
|
9
7
|
import { resolvePending, getPending } from "./pending-requests.js";
|
|
@@ -18,13 +16,6 @@ import { currentVersion, performUpdate } from "./update-checker.js";
|
|
|
18
16
|
import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
|
|
19
17
|
import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
|
|
20
18
|
|
|
21
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
|
|
23
|
-
const PLAN_GENERATION_PROMPT = fs.readFileSync(
|
|
24
|
-
path.join(__dirname, "commands", "plan-generation.md"),
|
|
25
|
-
"utf-8",
|
|
26
|
-
);
|
|
27
|
-
|
|
28
19
|
/**
|
|
29
20
|
* Parse RESULT frontmatter and conversation messages.
|
|
30
21
|
*/
|
|
@@ -115,39 +106,30 @@ function parseAttr(attrs: string, name: string): string | undefined {
|
|
|
115
106
|
}
|
|
116
107
|
|
|
117
108
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
109
|
+
* Generate a concise task name from a user prompt using the given agent.
|
|
110
|
+
* Falls back to the raw prompt on failure.
|
|
120
111
|
*/
|
|
121
|
-
async function
|
|
112
|
+
async function generateName(
|
|
122
113
|
projectRoot: string,
|
|
123
114
|
userPrompt: string,
|
|
124
115
|
agentName: string,
|
|
125
|
-
): Promise<
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const { command, args, stdin, env: agentEnv } =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (fmMatch) {
|
|
142
|
-
try {
|
|
143
|
-
const fm = parseYaml(fmMatch[1]) as { task_name?: string };
|
|
144
|
-
name = fm.task_name ?? "";
|
|
145
|
-
} catch {
|
|
146
|
-
// If frontmatter parsing fails, treat entire output as body
|
|
147
|
-
}
|
|
148
|
-
body = fmMatch[2].trimStart();
|
|
116
|
+
): Promise<string> {
|
|
117
|
+
const prompt = `Generate a concise 3-6 word name for this task. Reply with ONLY the name, nothing else.\n\nTask: ${userPrompt}`;
|
|
118
|
+
const agent = getAgent(agentName);
|
|
119
|
+
const { command, args, stdin, env: agentEnv } = agent.getPromptCommandLine(prompt);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const { output } = await spawnCommand(command, args, {
|
|
123
|
+
cwd: projectRoot,
|
|
124
|
+
timeout: 30_000,
|
|
125
|
+
stdin,
|
|
126
|
+
...(agentEnv ? { env: agentEnv } : {}),
|
|
127
|
+
});
|
|
128
|
+
const name = output.trim().replace(/^["']|["']$/g, "").slice(0, 80);
|
|
129
|
+
return name || userPrompt;
|
|
130
|
+
} catch {
|
|
131
|
+
return userPrompt;
|
|
149
132
|
}
|
|
150
|
-
return { name, body };
|
|
151
133
|
}
|
|
152
134
|
|
|
153
135
|
/** Active follow-up child processes, keyed by "taskId:runId". */
|
|
@@ -163,7 +145,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
163
145
|
const pending = getPending(task.frontmatter.id);
|
|
164
146
|
return {
|
|
165
147
|
...task.frontmatter,
|
|
166
|
-
body: task.body,
|
|
167
148
|
status: status ? {
|
|
168
149
|
...status,
|
|
169
150
|
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
@@ -215,25 +196,13 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
215
196
|
command?: string;
|
|
216
197
|
};
|
|
217
198
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (params.user_prompt.length <= 50) {
|
|
222
|
-
name = params.user_prompt;
|
|
223
|
-
} else {
|
|
224
|
-
try {
|
|
225
|
-
const plan = await generatePlan(config.projectRoot, params.user_prompt, params.agent);
|
|
226
|
-
name = plan.name;
|
|
227
|
-
body = plan.body;
|
|
228
|
-
} catch (err: unknown) {
|
|
229
|
-
const error = err as { stdout?: string; stderr?: string };
|
|
230
|
-
return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
|
|
231
|
-
}
|
|
232
|
-
}
|
|
199
|
+
const name = params.user_prompt.length <= 50
|
|
200
|
+
? params.user_prompt
|
|
201
|
+
: await generateName(config.projectRoot, params.user_prompt, params.agent);
|
|
233
202
|
|
|
234
203
|
const id = randomUUID();
|
|
235
204
|
const taskDir = getTaskDir(config.projectRoot, id);
|
|
236
|
-
const task = {
|
|
205
|
+
const task: ParsedTask = {
|
|
237
206
|
frontmatter: {
|
|
238
207
|
id,
|
|
239
208
|
name,
|
|
@@ -246,7 +215,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
246
215
|
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
247
216
|
...(params.command ? { command: params.command } : {}),
|
|
248
217
|
},
|
|
249
|
-
body,
|
|
250
218
|
};
|
|
251
219
|
|
|
252
220
|
writeTaskFile(taskDir, task);
|
|
@@ -272,10 +240,9 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
272
240
|
const taskDir = getTaskDir(config.projectRoot, params.id);
|
|
273
241
|
const existing = parseTaskFile(taskDir);
|
|
274
242
|
|
|
275
|
-
// Detect whether
|
|
243
|
+
// Detect whether name needs regeneration
|
|
276
244
|
const promptChanged = params.user_prompt !== undefined && params.user_prompt !== existing.frontmatter.user_prompt;
|
|
277
245
|
const agentChanged = params.agent !== undefined && params.agent !== existing.frontmatter.agent;
|
|
278
|
-
const needsRegeneration = promptChanged || agentChanged || !existing.body;
|
|
279
246
|
|
|
280
247
|
// Merge updates
|
|
281
248
|
if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
|
|
@@ -297,19 +264,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
297
264
|
}
|
|
298
265
|
}
|
|
299
266
|
|
|
300
|
-
// Regenerate
|
|
301
|
-
if (
|
|
302
|
-
existing.frontmatter.name = existing.frontmatter.user_prompt
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
try {
|
|
306
|
-
const plan = await generatePlan(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
|
|
307
|
-
existing.frontmatter.name = plan.name;
|
|
308
|
-
existing.body = plan.body;
|
|
309
|
-
} catch (err: unknown) {
|
|
310
|
-
const error = err as { stdout?: string; stderr?: string };
|
|
311
|
-
return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
|
|
312
|
-
}
|
|
267
|
+
// Regenerate name when prompt or agent changes
|
|
268
|
+
if (promptChanged || agentChanged) {
|
|
269
|
+
existing.frontmatter.name = existing.frontmatter.user_prompt.length <= 50
|
|
270
|
+
? existing.frontmatter.user_prompt
|
|
271
|
+
: await generateName(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
|
|
313
272
|
}
|
|
314
273
|
|
|
315
274
|
writeTaskFile(taskDir, existing);
|
|
@@ -356,7 +315,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
356
315
|
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
357
316
|
...(params.command ? { command: params.command } : {}),
|
|
358
317
|
},
|
|
359
|
-
body: "",
|
|
360
318
|
};
|
|
361
319
|
|
|
362
320
|
writeTaskFile(taskDir, task);
|
package/src/task.ts
CHANGED
|
@@ -29,7 +29,6 @@ export function parseTaskContent(content: string): ParsedTask {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const frontmatter = parseYaml(match[1]) as TaskFrontmatter;
|
|
32
|
-
const body = (match[2] || "").trim();
|
|
33
32
|
|
|
34
33
|
if (!frontmatter.id) {
|
|
35
34
|
throw new Error("TASK.md frontmatter must include at least: id");
|
|
@@ -39,7 +38,7 @@ export function parseTaskContent(content: string): ParsedTask {
|
|
|
39
38
|
frontmatter.agent ??= "claude";
|
|
40
39
|
frontmatter.triggers_enabled ??= true;
|
|
41
40
|
|
|
42
|
-
return { frontmatter
|
|
41
|
+
return { frontmatter };
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
/**
|
|
@@ -50,7 +49,7 @@ export function writeTaskFile(taskDir: string, task: ParsedTask): void {
|
|
|
50
49
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
51
50
|
|
|
52
51
|
const yamlStr = stringifyYaml(task.frontmatter).trim();
|
|
53
|
-
const content = `---\n${yamlStr}\n---\n
|
|
52
|
+
const content = `---\n${yamlStr}\n---\n`;
|
|
54
53
|
|
|
55
54
|
const filePath = path.join(taskDir, "TASK.md");
|
|
56
55
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
@@ -6,9 +6,10 @@ import { validateClient, addClient } from "../client-store.js";
|
|
|
6
6
|
import { registerPending } from "../pending-requests.js";
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
|
|
9
|
-
import { agentToolMap, ToolError, type ToolContext } from "../mcp-tools.js";
|
|
10
|
-
import { handleMcpRequest, getAgentName } from "../mcp-handler.js";
|
|
9
|
+
import { agentToolMap, agentResources, ToolError, type ToolContext } from "../mcp-tools.js";
|
|
10
|
+
import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
|
|
11
11
|
import { getTaskDir } from "../task.js";
|
|
12
|
+
import { onNotificationsChanged } from "../notification-store.js";
|
|
12
13
|
|
|
13
14
|
// ── Bundled PWA asset serving ───────────────────────────────────────────
|
|
14
15
|
|
|
@@ -102,9 +103,28 @@ export async function startHttpTransport(
|
|
|
102
103
|
onReady?: () => void,
|
|
103
104
|
): Promise<void> {
|
|
104
105
|
const sseClients = new Set<SseClient>();
|
|
106
|
+
const mcpStreams = new Map<string, http.ServerResponse>();
|
|
105
107
|
const lanEnabled = config.lanEnabled ?? false;
|
|
106
108
|
const bindAddress = lanEnabled ? "0.0.0.0" : "127.0.0.1";
|
|
107
109
|
|
|
110
|
+
/** Push notifications/resources/updated to all MCP clients subscribed to the given URI. */
|
|
111
|
+
function broadcastResourceUpdated(uri: string) {
|
|
112
|
+
const subs = getResourceSubscriptions();
|
|
113
|
+
for (const [sessionId, uris] of subs) {
|
|
114
|
+
if (!uris.has(uri)) continue;
|
|
115
|
+
const stream = mcpStreams.get(sessionId);
|
|
116
|
+
if (!stream) continue;
|
|
117
|
+
stream.write(`data: ${JSON.stringify({
|
|
118
|
+
jsonrpc: "2.0",
|
|
119
|
+
method: "notifications/resources/updated",
|
|
120
|
+
params: { uri },
|
|
121
|
+
})}\n\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Wire up resource change listeners
|
|
126
|
+
onNotificationsChanged(() => broadcastResourceUpdated("notifications://device"));
|
|
127
|
+
|
|
108
128
|
// If a pairing code is provided, pre-register it
|
|
109
129
|
if (pairingCode) {
|
|
110
130
|
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -182,7 +202,24 @@ export async function startHttpTransport(
|
|
|
182
202
|
if (result.sessionId) {
|
|
183
203
|
res.setHeader("Mcp-Session-Id", result.sessionId);
|
|
184
204
|
}
|
|
185
|
-
|
|
205
|
+
if (result.stream && sessionId) {
|
|
206
|
+
// Keep response open as SSE stream for server-initiated notifications
|
|
207
|
+
res.writeHead(200, {
|
|
208
|
+
"Content-Type": "text/event-stream",
|
|
209
|
+
"Cache-Control": "no-cache",
|
|
210
|
+
"Connection": "keep-alive",
|
|
211
|
+
});
|
|
212
|
+
res.write(`data: ${JSON.stringify(result.body)}\n\n`);
|
|
213
|
+
mcpStreams.set(sessionId, res);
|
|
214
|
+
const heartbeat = setInterval(() => { res.write(":heartbeat\n\n"); }, 15_000);
|
|
215
|
+
req.on("close", () => {
|
|
216
|
+
clearInterval(heartbeat);
|
|
217
|
+
mcpStreams.delete(sessionId);
|
|
218
|
+
getResourceSubscriptions().delete(sessionId);
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
sendJson(res, 200, result.body);
|
|
222
|
+
}
|
|
186
223
|
} catch (err) {
|
|
187
224
|
sendJson(res, 500, { error: String(err) });
|
|
188
225
|
}
|
|
@@ -220,6 +257,15 @@ export async function startHttpTransport(
|
|
|
220
257
|
return;
|
|
221
258
|
}
|
|
222
259
|
|
|
260
|
+
// ── Auto-generated REST endpoints from MCP resource registry ────
|
|
261
|
+
|
|
262
|
+
const matchedResource = req.method === "GET" && agentResources.find((r) => r.restPath === pathname);
|
|
263
|
+
if (matchedResource) {
|
|
264
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
265
|
+
sendJson(res, 200, matchedResource.read());
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
223
269
|
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
224
270
|
|
|
225
271
|
if (req.method === "POST" && pathname === "/event") {
|
package/src/types.ts
CHANGED
|
@@ -3,18 +3,72 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
|
-
import { generateEndpointDocs,
|
|
6
|
+
import { generateEndpointDocs, type ToolDefinition, type ResourceDefinition } from "../src/mcp-tools.js";
|
|
7
7
|
|
|
8
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const templatePath = path.join(__dirname, "..", "src", "agents", "agent-instructions.md");
|
|
10
10
|
const template = fs.readFileSync(templatePath, "utf-8");
|
|
11
11
|
|
|
12
|
+
/** Mock tools with a known, stable shape for testing */
|
|
13
|
+
const mockTools: ToolDefinition[] = [
|
|
14
|
+
{
|
|
15
|
+
name: "mock-action",
|
|
16
|
+
description: [
|
|
17
|
+
"Perform a mock action.",
|
|
18
|
+
'Response: `{"ok": true}` on success.',
|
|
19
|
+
],
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
title: { type: "string", description: "Action title" },
|
|
24
|
+
detail: { type: "string", description: "Optional detail" },
|
|
25
|
+
},
|
|
26
|
+
required: ["title"],
|
|
27
|
+
},
|
|
28
|
+
handler: async () => ({ ok: true }),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "mock-query",
|
|
32
|
+
description: [
|
|
33
|
+
"Query mock data from the device.",
|
|
34
|
+
"Blocks until the device responds.",
|
|
35
|
+
'Response: `{"data": ...}` on success.',
|
|
36
|
+
],
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
tags: {
|
|
41
|
+
type: "array",
|
|
42
|
+
items: { type: "string" },
|
|
43
|
+
description: "Filter tags",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
handler: async () => ({ data: [] }),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Mock resources with a known, stable shape for testing */
|
|
52
|
+
const mockResources: ResourceDefinition[] = [
|
|
53
|
+
{
|
|
54
|
+
uri: "mock://data",
|
|
55
|
+
name: "Mock Data",
|
|
56
|
+
description: [
|
|
57
|
+
"Get mock data from the device.",
|
|
58
|
+
"Response: JSON array of data objects.",
|
|
59
|
+
],
|
|
60
|
+
mimeType: "application/json",
|
|
61
|
+
restPath: "/mock-data",
|
|
62
|
+
read: () => [],
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
12
66
|
/** Minimal replica of getAgentInstructions that doesn't need host.json */
|
|
13
|
-
function buildInstructions(taskId: string, skipPermissions?: boolean): string {
|
|
67
|
+
function buildInstructions(taskId: string, opts?: { skipPermissions?: boolean }): string {
|
|
14
68
|
let instructions = template
|
|
15
|
-
.replace(/\{\{ENDPOINT_DOCS\}\}/g, generateEndpointDocs(9966, taskId))
|
|
69
|
+
.replace(/\{\{ENDPOINT_DOCS\}\}/g, generateEndpointDocs(9966, taskId, mockTools, mockResources))
|
|
16
70
|
.replace(/\{\{TASK_DESCRIPTION\}\}/g, "Test task prompt");
|
|
17
|
-
if (skipPermissions) {
|
|
71
|
+
if (opts?.skipPermissions) {
|
|
18
72
|
instructions = instructions.replace(/## Permissions\r?\n[\s\S]*?(?=## |\r?\n---)/m, "");
|
|
19
73
|
}
|
|
20
74
|
return instructions;
|
|
@@ -28,13 +82,13 @@ describe("getAgentInstructions", () => {
|
|
|
28
82
|
});
|
|
29
83
|
|
|
30
84
|
it("strips Permissions section when skipPermissions is true", () => {
|
|
31
|
-
const result = buildInstructions("test-task-id", true);
|
|
85
|
+
const result = buildInstructions("test-task-id", { skipPermissions: true });
|
|
32
86
|
assert.doesNotMatch(result, /## Permissions/);
|
|
33
87
|
assert.doesNotMatch(result, /PALMIER_PERMISSION/);
|
|
34
88
|
});
|
|
35
89
|
|
|
36
90
|
it("preserves other sections when Permissions is stripped", () => {
|
|
37
|
-
const result = buildInstructions("test-task-id", true);
|
|
91
|
+
const result = buildInstructions("test-task-id", { skipPermissions: true });
|
|
38
92
|
assert.match(result, /## Reporting Output/);
|
|
39
93
|
assert.match(result, /## Completion/);
|
|
40
94
|
assert.match(result, /## HTTP Endpoints/);
|
|
@@ -60,22 +114,41 @@ describe("getAgentInstructions", () => {
|
|
|
60
114
|
const result = buildInstructions("test");
|
|
61
115
|
assert.match(result, /Test task prompt/);
|
|
62
116
|
});
|
|
63
|
-
});
|
|
64
117
|
|
|
65
|
-
describe("full agent instruction snapshot", () => {
|
|
66
|
-
it("matches the expected full text exactly", () => {
|
|
67
|
-
const result = buildInstructions("test-task-id").replace(/\r\n/g, "\n").trimEnd();
|
|
68
|
-
const snapshotPath = path.join(__dirname, "fixtures", "agent-instructions-snapshot.md");
|
|
69
|
-
const expected = fs.readFileSync(snapshotPath, "utf-8").replace(/\r\n/g, "\n").trimEnd();
|
|
70
|
-
assert.equal(result, expected);
|
|
71
|
-
});
|
|
72
118
|
});
|
|
73
119
|
|
|
120
|
+
|
|
74
121
|
describe("generateEndpointDocs", () => {
|
|
75
|
-
const docs = generateEndpointDocs(9966, "test-id");
|
|
122
|
+
const docs = generateEndpointDocs(9966, "test-id", mockTools, mockResources);
|
|
123
|
+
|
|
124
|
+
it("matches expected full output", () => {
|
|
125
|
+
const expected = [
|
|
126
|
+
"The following HTTP endpoints are available during task execution. Use curl to call them.",
|
|
127
|
+
"",
|
|
128
|
+
"**`POST http://localhost:9966/mock-action?taskId=test-id`** — Perform a mock action.",
|
|
129
|
+
"```json",
|
|
130
|
+
'{"title":"...","detail":"..."}',
|
|
131
|
+
"```",
|
|
132
|
+
"- `title` (required, string): Action title",
|
|
133
|
+
"- `detail` (optional, string): Optional detail",
|
|
134
|
+
'- Response: `{"ok": true}` on success.',
|
|
135
|
+
"",
|
|
136
|
+
"**`POST http://localhost:9966/mock-query?taskId=test-id`** — Query mock data from the device.",
|
|
137
|
+
"```json",
|
|
138
|
+
'{"tags":["..."]}',
|
|
139
|
+
"```",
|
|
140
|
+
"- `tags` (optional, string array): Filter tags",
|
|
141
|
+
"- Blocks until the device responds.",
|
|
142
|
+
'- Response: `{"data": ...}` on success.',
|
|
143
|
+
"",
|
|
144
|
+
"**`GET http://localhost:9966/mock-data`** — Get mock data from the device.",
|
|
145
|
+
"- Response: JSON array of data objects.",
|
|
146
|
+
].join("\n");
|
|
147
|
+
assert.equal(docs, expected);
|
|
148
|
+
});
|
|
76
149
|
|
|
77
|
-
it("generates docs for all
|
|
78
|
-
for (const tool of
|
|
150
|
+
it("generates docs for all provided tools", () => {
|
|
151
|
+
for (const tool of mockTools) {
|
|
79
152
|
assert.match(docs, new RegExp(`POST http://localhost:9966/${tool.name}\\?taskId=`), `Missing endpoint for ${tool.name}`);
|
|
80
153
|
}
|
|
81
154
|
});
|
|
@@ -97,8 +170,8 @@ describe("generateEndpointDocs", () => {
|
|
|
97
170
|
});
|
|
98
171
|
|
|
99
172
|
it("includes response descriptions", () => {
|
|
100
|
-
for (const tool of
|
|
101
|
-
if (tool.
|
|
173
|
+
for (const tool of mockTools) {
|
|
174
|
+
if (tool.description.length > 1) {
|
|
102
175
|
assert.match(docs, /Response:/, `Missing response description for ${tool.name}`);
|
|
103
176
|
}
|
|
104
177
|
}
|
|
@@ -106,6 +179,31 @@ describe("generateEndpointDocs", () => {
|
|
|
106
179
|
|
|
107
180
|
it("marks required and optional parameters correctly", () => {
|
|
108
181
|
assert.match(docs, /\(required, string\)/);
|
|
182
|
+
// "detail" has no required entry, so it should be optional
|
|
109
183
|
assert.match(docs, /\(optional, string\)/);
|
|
110
184
|
});
|
|
185
|
+
|
|
186
|
+
it("handles array-type parameters", () => {
|
|
187
|
+
assert.match(docs, /\(optional, string array\)/);
|
|
188
|
+
assert.match(docs, /Filter tags/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("renders multi-line descriptions as bullet points", () => {
|
|
192
|
+
assert.match(docs, /- Blocks until the device responds\./);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("generates GET endpoints for all provided resources", () => {
|
|
196
|
+
for (const resource of mockResources) {
|
|
197
|
+
assert.match(docs, new RegExp(`GET http://localhost:9966${resource.restPath}`), `Missing endpoint for ${resource.uri}`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("includes resource description as bullet points", () => {
|
|
202
|
+
assert.match(docs, /- Response: JSON array of data objects\./);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("generates no resource endpoints when resources array is empty", () => {
|
|
206
|
+
const docsNoResources = generateEndpointDocs(9966, "test-id", mockTools, []);
|
|
207
|
+
assert.doesNotMatch(docsNoResources, /GET http/);
|
|
208
|
+
});
|
|
111
209
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
// Re-import fresh module state for each test file run
|
|
5
|
+
// Since the store is module-level state, we test the exported functions directly
|
|
6
|
+
import { addNotification, getNotifications, onNotificationsChanged, type DeviceNotification } from "../src/notification-store.js";
|
|
7
|
+
|
|
8
|
+
function makeNotification(id: string, overrides?: Partial<DeviceNotification>): DeviceNotification {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
packageName: "com.example.app",
|
|
12
|
+
appName: "Example",
|
|
13
|
+
title: `Title ${id}`,
|
|
14
|
+
text: `Text ${id}`,
|
|
15
|
+
timestamp: Date.now(),
|
|
16
|
+
receivedAt: Date.now(),
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("notification-store", () => {
|
|
22
|
+
it("stores and retrieves notifications", () => {
|
|
23
|
+
const before = getNotifications().length;
|
|
24
|
+
addNotification(makeNotification("test-1"));
|
|
25
|
+
const after = getNotifications();
|
|
26
|
+
assert.equal(after.length, before + 1);
|
|
27
|
+
assert.equal(after[after.length - 1].id, "test-1");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns a defensive copy", () => {
|
|
31
|
+
const a = getNotifications();
|
|
32
|
+
const b = getNotifications();
|
|
33
|
+
assert.notStrictEqual(a, b);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("evicts oldest when exceeding max", () => {
|
|
37
|
+
const before = getNotifications().length;
|
|
38
|
+
// Add enough to exceed 50
|
|
39
|
+
for (let i = 0; i < 60; i++) {
|
|
40
|
+
addNotification(makeNotification(`evict-${i}`));
|
|
41
|
+
}
|
|
42
|
+
const result = getNotifications();
|
|
43
|
+
assert.ok(result.length <= 50, `Expected <= 50, got ${result.length}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("notifies listeners on add", () => {
|
|
47
|
+
let called = 0;
|
|
48
|
+
const unsub = onNotificationsChanged(() => { called++; });
|
|
49
|
+
addNotification(makeNotification("listener-1"));
|
|
50
|
+
assert.equal(called, 1);
|
|
51
|
+
addNotification(makeNotification("listener-2"));
|
|
52
|
+
assert.equal(called, 2);
|
|
53
|
+
unsub();
|
|
54
|
+
addNotification(makeNotification("listener-3"));
|
|
55
|
+
assert.equal(called, 2); // no longer called after unsubscribe
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -19,7 +19,7 @@ This is the task body.`;
|
|
|
19
19
|
assert.equal(result.frontmatter.id, "abc123");
|
|
20
20
|
assert.equal(result.frontmatter.name, "Test Task");
|
|
21
21
|
assert.equal(result.frontmatter.agent, "claude");
|
|
22
|
-
assert.equal(result.
|
|
22
|
+
assert.equal(result.frontmatter.user_prompt, "Do something");
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
it("defaults agent to claude when not specified", () => {
|
|
@@ -68,7 +68,7 @@ requires_confirmation: false
|
|
|
68
68
|
assert.throws(() => parseTaskContent("---\nname: test\n---\n"), /must include at least: id/);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it("handles empty body", () => {
|
|
71
|
+
it("handles empty body gracefully", () => {
|
|
72
72
|
const content = `---
|
|
73
73
|
id: abc123
|
|
74
74
|
user_prompt: test
|
|
@@ -78,6 +78,6 @@ requires_confirmation: false
|
|
|
78
78
|
---`;
|
|
79
79
|
|
|
80
80
|
const result = parseTaskContent(content);
|
|
81
|
-
assert.equal(result.
|
|
81
|
+
assert.equal(result.frontmatter.id, "abc123");
|
|
82
82
|
});
|
|
83
83
|
});
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
You are a task planning assistant. Given a task description, produce a Markdown execution plan for an AI agent to follow. Do not execute any part of the plan yourself.
|
|
2
|
-
|
|
3
|
-
## Output Format
|
|
4
|
-
|
|
5
|
-
Start with a YAML frontmatter block (no code fences), then the plan body:
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
task_name: <concise label, 3-6 words>
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
<plan body>
|
|
12
|
-
|
|
13
|
-
## Plan Body Guidelines
|
|
14
|
-
|
|
15
|
-
- Write a numbered sequence of concrete, actionable steps.
|
|
16
|
-
- If the task produces formatted output (report, email, summary, etc.), specify the structure, sections, and tone.
|
|
17
|
-
- When a step requires user input, simply state what information is needed from the user. Do **not** specify how to obtain it — the agent has its own tool for requesting user input.
|
|
18
|
-
- Preserve relative time expressions (e.g., "today", "yesterday", "last week") exactly as written — do **not** resolve them to specific dates. The plan may be executed on a different day than it was generated.
|
|
19
|
-
- If the task involves opening a web browser or application, include a final step to close it before finishing.
|
|
20
|
-
|
|
21
|
-
## Task Description
|
|
22
|
-
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
You are a task planning assistant. Given a task description, produce a Markdown execution plan for an AI agent to follow. Do not execute any part of the plan yourself.
|
|
2
|
-
|
|
3
|
-
## Output Format
|
|
4
|
-
|
|
5
|
-
Start with a YAML frontmatter block (no code fences), then the plan body:
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
task_name: <concise label, 3-6 words>
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
<plan body>
|
|
12
|
-
|
|
13
|
-
## Plan Body Guidelines
|
|
14
|
-
|
|
15
|
-
- Write a numbered sequence of concrete, actionable steps.
|
|
16
|
-
- If the task produces formatted output (report, email, summary, etc.), specify the structure, sections, and tone.
|
|
17
|
-
- When a step requires user input, simply state what information is needed from the user. Do **not** specify how to obtain it — the agent has its own tool for requesting user input.
|
|
18
|
-
- Preserve relative time expressions (e.g., "today", "yesterday", "last week") exactly as written — do **not** resolve them to specific dates. The plan may be executed on a different day than it was generated.
|
|
19
|
-
- If the task involves opening a web browser or application, include a final step to close it before finishing.
|
|
20
|
-
|
|
21
|
-
## Task Description
|
|
22
|
-
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
You are an AI agent executing a task on behalf of the user. Follow these instructions carefully.
|
|
2
|
-
|
|
3
|
-
## Reporting Output
|
|
4
|
-
|
|
5
|
-
If you generate report or output files, print each file path on its own line using this exact format:
|
|
6
|
-
[PALMIER_REPORT] <filename>
|
|
7
|
-
|
|
8
|
-
## Completion
|
|
9
|
-
|
|
10
|
-
When you are done, output exactly one of these markers as the very last line (no other text on the same line):
|
|
11
|
-
[PALMIER_TASK_SUCCESS]
|
|
12
|
-
[PALMIER_TASK_FAILURE]
|
|
13
|
-
|
|
14
|
-
## Permissions
|
|
15
|
-
|
|
16
|
-
Whenever a tool you are trying to use is denied or you lack the required permissions, print each required permission on its own line using this exact format:
|
|
17
|
-
[PALMIER_PERMISSION] <tool_name> | <description>
|
|
18
|
-
|
|
19
|
-
## HTTP Endpoints
|
|
20
|
-
|
|
21
|
-
The following HTTP endpoints are available during task execution. Use curl to call them.
|
|
22
|
-
|
|
23
|
-
**`POST http://localhost:9966/notify?taskId=test-task-id`** — Send a push notification to the user's device.
|
|
24
|
-
```json
|
|
25
|
-
{"title":"...","body":"..."}
|
|
26
|
-
```
|
|
27
|
-
- `title` (required, string): Notification title
|
|
28
|
-
- `body` (required, string): Notification body
|
|
29
|
-
- Response: `{"ok": true}` on success.
|
|
30
|
-
|
|
31
|
-
**`POST http://localhost:9966/request-input?taskId=test-task-id`** — Request input from the user.
|
|
32
|
-
```json
|
|
33
|
-
{"description":"...","questions":["..."]}
|
|
34
|
-
```
|
|
35
|
-
- `description` (optional, string): Context or heading for the input request
|
|
36
|
-
- `questions` (required, string array): Questions to present to the user
|
|
37
|
-
- The request blocks until the user responds.
|
|
38
|
-
- Response: `{"values": ["answer1", "answer2"]}` on success, or `{"aborted": true}` if the user declines.
|
|
39
|
-
- When you need information from the user (credentials, answers to questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout, even in a non-interactive environment — use this endpoint instead.
|
|
40
|
-
|
|
41
|
-
**`POST http://localhost:9966/request-confirmation?taskId=test-task-id`** — Request confirmation from the user.
|
|
42
|
-
```json
|
|
43
|
-
{"description":"..."}
|
|
44
|
-
```
|
|
45
|
-
- `description` (required, string): What the user is confirming
|
|
46
|
-
- The request blocks until the user confirms or aborts.
|
|
47
|
-
- Response: `{"confirmed": true}` or `{"confirmed": false}`.
|
|
48
|
-
|
|
49
|
-
**`POST http://localhost:9966/device-geolocation?taskId=test-task-id`** — Get the GPS location of the user's mobile device.
|
|
50
|
-
- When you need the user's real-time location, use this endpoint.
|
|
51
|
-
- Blocks until the device responds (up to 30 seconds).
|
|
52
|
-
- Response: `{"latitude": ..., "longitude": ..., "accuracy": ..., "timestamp": ...}` on success, or `{"error": "..."}` on failure.
|
|
53
|
-
|
|
54
|
-
The task to execute follows below:
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
Test task prompt
|