@openape/ape-agent 2.1.0 → 2.3.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.
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  name: bash
3
3
  description: When a task can't be done with the other curated tools (file.read, http.get, tasks.create, mail.list, etc.), use bash — runs any shell command on the agent host through the DDISA grant cycle.
4
- requires_tools: [bash]
4
+ metadata:
5
+ openape:
6
+ requires_tools: [bash]
5
7
  ---
6
8
 
7
9
  # Shell access via ape-shell
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  name: file
3
3
  description: When the user asks you to read, write, or check a file in your home directory, use the file.read / file.write tools — they're $HOME-jailed and safer than bash cat.
4
- requires_tools: [file.read]
4
+ metadata:
5
+ openape:
6
+ requires_tools: [file.read]
5
7
  ---
6
8
 
7
9
  # Files in $HOME
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  name: http
3
3
  description: When the user asks to fetch a webpage, hit a REST API, or POST JSON to an endpoint, use the http.get / http.post tools — never invent URLs.
4
- requires_tools: [http.get]
4
+ metadata:
5
+ openape:
6
+ requires_tools: [http.get]
5
7
  ---
6
8
 
7
9
  # HTTP fetch
@@ -1,7 +1,15 @@
1
1
  ---
2
2
  name: mail
3
3
  description: When the user asks about their inbox — what's there, search for an email, recent unread — use mail.list / mail.search.
4
- requires_tools: [mail.list]
4
+ metadata:
5
+ openape:
6
+ requires_tools: [mail.list]
7
+ openclaw:
8
+ # If the o365-cli binary isn't on PATH the underlying tool fails
9
+ # at runtime — skip the skill from the prompt so the LLM doesn't
10
+ # try to use it on a host where it can't run.
11
+ requires:
12
+ bins: [o365-cli]
5
13
  ---
6
14
 
7
15
  # Inbox (o365-cli)
@@ -1,7 +1,15 @@
1
1
  ---
2
2
  name: tasks
3
3
  description: When the user wants to see, create, or schedule a task/reminder/wiedervorlage on their personal task list, use tasks.list / tasks.create.
4
- requires_tools: [tasks.list]
4
+ metadata:
5
+ openape:
6
+ requires_tools: [tasks.list]
7
+ openclaw:
8
+ # The bash escape-hatch path uses ape-tasks; surface it as a soft
9
+ # dependency so on a host without ape-tasks the LLM is told this
10
+ # skill doesn't apply.
11
+ requires:
12
+ bins: [ape-tasks]
5
13
  ---
6
14
 
7
15
  # Personal tasks (ape-tasks)
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  name: time
3
3
  description: When the user asks for the current time, date, or wants a sanity-check that the runtime is alive, use the time.now tool — never guess.
4
- requires_tools: [time.now]
4
+ metadata:
5
+ openape:
6
+ requires_tools: [time.now]
5
7
  ---
6
8
 
7
9
  # Time and date
package/dist/bridge.mjs CHANGED
@@ -1353,11 +1353,12 @@ var ChatApi = class {
1353
1353
  this.bearer = bearer;
1354
1354
  }
1355
1355
  async postMessage(roomId, body, opts = {}) {
1356
- const trimmed = clamp(body, MAX_BODY);
1356
+ const bodyForServer = opts.streaming ? body : clamp(body, MAX_BODY);
1357
1357
  const url = `${this.endpoint}/api/rooms/${encodeURIComponent(roomId)}/messages`;
1358
- const payload = { body: trimmed };
1358
+ const payload = { body: bodyForServer };
1359
1359
  if (opts.replyTo) payload.reply_to = opts.replyTo;
1360
1360
  if (opts.threadId) payload.thread_id = opts.threadId;
1361
+ if (opts.streaming) payload.streaming = true;
1361
1362
  const result = await ofetch5(url, {
1362
1363
  method: "POST",
1363
1364
  headers: { Authorization: await this.bearer() },
@@ -1401,13 +1402,32 @@ var ChatApi = class {
1401
1402
  body: { name: name.slice(0, 100) }
1402
1403
  });
1403
1404
  }
1404
- async patchMessage(messageId, body) {
1405
- const trimmed = clamp(body, MAX_BODY);
1405
+ /**
1406
+ * Update an in-flight or completed message. The server differentiates
1407
+ * three modes via the message's current `streaming` state and the
1408
+ * `streaming` field in this call:
1409
+ *
1410
+ * - Stream tick: pass `body` only (current accumulated text).
1411
+ * Server keeps streaming=true and does NOT bump edited_at.
1412
+ * - Stream end: pass `body` + `streaming: false`. Server clears
1413
+ * the streaming flag and triggers the user-facing push.
1414
+ * - Tool-call status: pass `streamingStatus` only (no body).
1415
+ * Renders as "🔧 time.now" in the typing-subtitle.
1416
+ * - Tool-call cleared: pass `streamingStatus: null`.
1417
+ */
1418
+ async patchMessage(messageId, opts = {}) {
1406
1419
  const url = `${this.endpoint}/api/messages/${encodeURIComponent(messageId)}`;
1420
+ const payload = {};
1421
+ if (opts.body !== void 0) {
1422
+ payload.body = opts.streaming === false && opts.body.trim().length === 0 ? clamp(opts.body, MAX_BODY) : opts.body.length <= MAX_BODY ? opts.body : `${opts.body.slice(0, MAX_BODY - 1)}\u2026`;
1423
+ }
1424
+ if (opts.streaming !== void 0) payload.streaming = opts.streaming;
1425
+ if (opts.streamingStatus !== void 0) payload.streaming_status = opts.streamingStatus;
1426
+ if (Object.keys(payload).length === 0) return;
1407
1427
  await ofetch5(url, {
1408
1428
  method: "PATCH",
1409
1429
  headers: { Authorization: await this.bearer() },
1410
- body: { body: trimmed }
1430
+ body: payload
1411
1431
  });
1412
1432
  }
1413
1433
  };
@@ -3800,10 +3820,12 @@ function shouldAutoAccept(peerEmail, identity, allowlist) {
3800
3820
  }
3801
3821
 
3802
3822
  // src/skills.ts
3823
+ import { execFileSync as execFileSync3 } from "child_process";
3803
3824
  import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync } from "fs";
3804
3825
  import { homedir as homedir8 } from "os";
3805
3826
  import { dirname as dirname2, join as join8, resolve as resolve2 } from "path";
3806
3827
  import { fileURLToPath } from "url";
3828
+ import { parse as parseYaml } from "yaml";
3807
3829
  var SKILLS_SUBDIR = [".openape", "agent", "skills"];
3808
3830
  var SOUL_PATH_PARTS = [".openape", "agent", "SOUL.md"];
3809
3831
  function soulPath(home = homedir8()) {
@@ -3828,40 +3850,48 @@ function parseFrontmatter(content) {
3828
3850
  const closeIdx = trimmed.indexOf("\n---", 3);
3829
3851
  if (closeIdx < 0) return null;
3830
3852
  const fmBlock = trimmed.slice(3, closeIdx).trim();
3831
- const fields = {};
3832
- let arrayKey = null;
3833
- let arrayBuf = [];
3834
- for (const rawLine of fmBlock.split("\n")) {
3835
- const line = rawLine.replace(/\r$/, "");
3836
- if (arrayKey) {
3837
- const m2 = line.match(/^[\t ]*-[\t ]+(\S.*)$/);
3838
- if (m2) {
3839
- arrayBuf.push(m2[1].trim());
3840
- continue;
3841
- }
3842
- fields[arrayKey] = arrayBuf.join(",");
3843
- arrayKey = null;
3844
- arrayBuf = [];
3845
- }
3846
- const kv = line.match(/^([a-z_]\w*)[\t ]*:[\t ]?(.*)$/i);
3847
- if (!kv) continue;
3848
- const [, key, value] = kv;
3849
- if (value.trim() === "") {
3850
- arrayKey = key;
3851
- arrayBuf = [];
3852
- continue;
3853
- }
3854
- const inlineArray = value.match(/^\[(.*)\]$/);
3855
- if (inlineArray) {
3856
- fields[key] = inlineArray[1].split(",").map((s2) => s2.trim().replace(/^["']|["']$/g, "")).filter(Boolean).join(",");
3857
- continue;
3853
+ let parsed;
3854
+ try {
3855
+ parsed = parseYaml(fmBlock);
3856
+ } catch {
3857
+ return null;
3858
+ }
3859
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
3860
+ const fields = parsed;
3861
+ const name = typeof fields.name === "string" ? fields.name.trim() : "";
3862
+ const description = typeof fields.description === "string" ? fields.description.trim() : "";
3863
+ if (!name || !description) return null;
3864
+ function asStringArray(v2) {
3865
+ if (!Array.isArray(v2)) return void 0;
3866
+ const out = v2.map((x2) => typeof x2 === "string" ? x2.trim() : "").filter((s2) => s2.length > 0);
3867
+ return out.length > 0 ? out : void 0;
3868
+ }
3869
+ const meta = fields.metadata && typeof fields.metadata === "object" && !Array.isArray(fields.metadata) ? fields.metadata : {};
3870
+ const openapeMeta = meta.openape && typeof meta.openape === "object" && !Array.isArray(meta.openape) ? meta.openape : {};
3871
+ const openclawMeta = meta.openclaw && typeof meta.openclaw === "object" && !Array.isArray(meta.openclaw) ? meta.openclaw : {};
3872
+ const requiresTools = asStringArray(openapeMeta.requires_tools) ?? asStringArray(fields.requires_tools);
3873
+ function readRequiresBins(scope) {
3874
+ const requires = scope.requires;
3875
+ if (requires && typeof requires === "object" && !Array.isArray(requires)) {
3876
+ return asStringArray(requires.bins);
3858
3877
  }
3859
- fields[key] = value.trim().replace(/^["']|["']$/g, "");
3878
+ return void 0;
3860
3879
  }
3861
- if (arrayKey) fields[arrayKey] = arrayBuf.join(",");
3862
- if (!fields.name || !fields.description) return null;
3863
- const requiresTools = fields.requires_tools ? fields.requires_tools.split(",").map((s2) => s2.trim()).filter(Boolean) : void 0;
3864
- return { name: fields.name, description: fields.description, requiresTools };
3880
+ const requiresBins = readRequiresBins(openclawMeta) ?? readRequiresBins(openapeMeta) ?? asStringArray(fields.requires_bins);
3881
+ return { name, description, requiresTools, requiresBins };
3882
+ }
3883
+ var binCheckCache = /* @__PURE__ */ new Map();
3884
+ function hasBinaryOnPath(bin) {
3885
+ const cached = binCheckCache.get(bin);
3886
+ if (cached !== void 0) return cached;
3887
+ let found = false;
3888
+ try {
3889
+ execFileSync3("/usr/bin/which", [bin], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
3890
+ found = true;
3891
+ } catch {
3892
+ }
3893
+ binCheckCache.set(bin, found);
3894
+ return found;
3865
3895
  }
3866
3896
  function scanSkillsDir(dir) {
3867
3897
  if (!existsSync6(dir)) return [];
@@ -3894,7 +3924,8 @@ function scanSkillsDir(dir) {
3894
3924
  name: fm.name,
3895
3925
  description: fm.description,
3896
3926
  filePath: skillPath,
3897
- requiresTools: fm.requiresTools
3927
+ requiresTools: fm.requiresTools,
3928
+ requiresBins: fm.requiresBins
3898
3929
  });
3899
3930
  }
3900
3931
  return out;
@@ -3914,6 +3945,10 @@ function composeSkills(home, enabledTools) {
3914
3945
  const allPresent = s2.requiresTools.every((t2) => enabled.has(t2));
3915
3946
  if (!allPresent) continue;
3916
3947
  }
3948
+ if (s2.requiresBins && s2.requiresBins.length > 0) {
3949
+ const allBinsPresent = s2.requiresBins.every((b2) => hasBinaryOnPath(b2));
3950
+ if (!allBinsPresent) continue;
3951
+ }
3917
3952
  out.push(s2);
3918
3953
  }
3919
3954
  out.sort((a2, b2) => a2.name.localeCompare(b2.name));
@@ -4025,9 +4060,10 @@ var ThreadSession = class {
4025
4060
  void this.startTurn(body, replyToMessageId);
4026
4061
  }
4027
4062
  async startTurn(body, replyToMessageId) {
4028
- const placeholder = await this.deps.chat.postMessage(this.deps.roomId, "\u2026", {
4063
+ const placeholder = await this.deps.chat.postMessage(this.deps.roomId, "", {
4029
4064
  replyTo: replyToMessageId,
4030
- threadId: this.deps.threadId
4065
+ threadId: this.deps.threadId,
4066
+ streaming: true
4031
4067
  });
4032
4068
  const turn = {
4033
4069
  placeholderId: placeholder.id,
@@ -4035,15 +4071,23 @@ var ThreadSession = class {
4035
4071
  replyToMessageId,
4036
4072
  throttle: createThrottle(async () => {
4037
4073
  if (!this.active || this.active.placeholderId !== placeholder.id) return;
4038
- const text = this.active.accumulated || "\u2026";
4074
+ const text = this.active.accumulated;
4075
+ if (text.length === 0) return;
4039
4076
  try {
4040
- await this.deps.chat.patchMessage(placeholder.id, text);
4077
+ await this.deps.chat.patchMessage(placeholder.id, { body: text });
4041
4078
  } catch (err) {
4042
4079
  this.deps.log(`patch failed (room=${this.deps.roomId} thread=${this.deps.threadId}): ${err instanceof Error ? err.message : String(err)}`);
4043
4080
  }
4044
4081
  }, PATCH_INTERVAL_MS)
4045
4082
  };
4046
4083
  this.active = turn;
4084
+ const setStatus = async (status) => {
4085
+ try {
4086
+ await this.deps.chat.patchMessage(placeholder.id, { streamingStatus: status });
4087
+ } catch (err) {
4088
+ this.deps.log(`status patch failed: ${err instanceof Error ? err.message : String(err)}`);
4089
+ }
4090
+ };
4047
4091
  try {
4048
4092
  const result = await runLoop({
4049
4093
  config: this.deps.runtimeConfig,
@@ -4060,12 +4104,15 @@ var ThreadSession = class {
4060
4104
  },
4061
4105
  onToolCall: ({ name }) => {
4062
4106
  this.deps.log(`[${this.deps.roomId}/${this.deps.threadId.slice(0, 8)}] tool_call: ${name}`);
4107
+ void setStatus(`\u{1F527} ${name}`);
4063
4108
  },
4064
4109
  onToolResult: ({ name }) => {
4065
4110
  this.deps.log(`[${this.deps.roomId}/${this.deps.threadId.slice(0, 8)}] tool_result: ${name}`);
4111
+ void setStatus(null);
4066
4112
  },
4067
4113
  onToolError: ({ name, error }) => {
4068
4114
  this.deps.log(`[${this.deps.roomId}/${this.deps.threadId.slice(0, 8)}] tool_error: ${name} \u2192 ${error}`);
4115
+ void setStatus(null);
4069
4116
  }
4070
4117
  }
4071
4118
  });
@@ -4076,28 +4123,51 @@ var ThreadSession = class {
4076
4123
  if (result.status === "error") {
4077
4124
  this.deps.log(`runtime done with status=error (room=${this.deps.roomId} thread=${this.deps.threadId})`);
4078
4125
  }
4079
- this.endTurn();
4126
+ await this.endTurn();
4080
4127
  } catch (err) {
4081
4128
  const message = err instanceof Error ? err.message : String(err);
4082
4129
  this.deps.log(`runtime error (room=${this.deps.roomId} thread=${this.deps.threadId}): ${message}`);
4083
- this.failTurn(`(runtime error: ${message})`);
4130
+ await this.failTurn(`(runtime error: ${message})`);
4084
4131
  }
4085
4132
  }
4086
- endTurn() {
4133
+ /**
4134
+ * Stream-end: flush any pending throttled body PATCH, then mark the
4135
+ * message as no-longer-streaming. The combined call also triggers
4136
+ * the user-facing push (the placeholder POST suppressed it).
4137
+ */
4138
+ async endTurn() {
4087
4139
  const turn = this.active;
4088
4140
  if (!turn) return;
4089
4141
  turn.throttle.flush();
4142
+ try {
4143
+ await this.deps.chat.patchMessage(turn.placeholderId, {
4144
+ body: turn.accumulated || "(empty response)",
4145
+ streaming: false,
4146
+ streamingStatus: null
4147
+ });
4148
+ } catch (err) {
4149
+ this.deps.log(`stream-end patch failed: ${err instanceof Error ? err.message : String(err)}`);
4150
+ }
4090
4151
  this.active = void 0;
4091
4152
  const next = this.queue.shift();
4092
4153
  if (next) {
4093
4154
  void this.startTurn(next.body, next.replyToMessageId);
4094
4155
  }
4095
4156
  }
4096
- failTurn(message) {
4157
+ async failTurn(message) {
4097
4158
  const turn = this.active;
4098
4159
  if (!turn) return;
4099
4160
  turn.accumulated = message;
4100
4161
  turn.throttle.flush();
4162
+ try {
4163
+ await this.deps.chat.patchMessage(turn.placeholderId, {
4164
+ body: message,
4165
+ streaming: false,
4166
+ streamingStatus: null
4167
+ });
4168
+ } catch (err) {
4169
+ this.deps.log(`fail-turn patch failed: ${err instanceof Error ? err.message : String(err)}`);
4170
+ }
4101
4171
  this.active = void 0;
4102
4172
  const next = this.queue.shift();
4103
4173
  if (next) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openape/ape-agent",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "OpenApe agent runtime: per-agent process that connects to chat.openape.ai, runs the LLM loop with tools + cron tasks, and streams replies back to owners.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,6 +21,7 @@
21
21
  "jose": "^5.9.0",
22
22
  "ofetch": "^1.4.1",
23
23
  "ws": "^8.18.0",
24
+ "yaml": "^2.8.0",
24
25
  "@openape/apes": "1.22.0",
25
26
  "@openape/cli-auth": "0.4.0"
26
27
  },