@openape/ape-agent 2.0.1 → 2.1.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.
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: bash
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]
5
+ ---
6
+
7
+ # Shell access via ape-shell
8
+
9
+ ## What this is
10
+
11
+ The `bash` tool spawns `ape-shell -c '<cmd>'` on the agent host. `ape-shell` is the human owner's interactive shell wrapper — every command goes through the OpenApe DDISA grant cycle:
12
+
13
+ - Auto-approved if a YOLO scope matches (owner has pre-approved this command pattern)
14
+ - Otherwise the owner gets a push notification on their phone with the exact command to approve or deny
15
+ - Approval takes ~3–15s typically; budget is 5min before the call times out
16
+
17
+ You run as the agent's hidden macOS user, so the filesystem and network you see are what *that* user sees — already jailed.
18
+
19
+ ## When to prefer bash over a curated tool
20
+
21
+ Curated tools (time.now, http.get, file.read, tasks.list, mail.list) are **always preferred** when they apply:
22
+
23
+ - Faster (no grant cycle)
24
+ - Structured JSON returns instead of stdout parsing
25
+ - Lower risk score (visible to the owner in troop UI)
26
+
27
+ Reach for `bash` when:
28
+
29
+ - The curated tool doesn't cover the case (e.g. `git status`, `iurio cases search`, `o365-cli mail trash <id>`)
30
+ - You need to chain commands with pipes / loops / redirects
31
+ - You need an auth header the deny-list strips (e.g. `curl -H 'Authorization: …'`)
32
+
33
+ ## Patterns
34
+
35
+ Read system info:
36
+
37
+ ```
38
+ bash({ "cmd": "uname -a && uptime" })
39
+ ```
40
+
41
+ Run a CLI:
42
+
43
+ ```
44
+ bash({ "cmd": "ape-tasks list --status open,doing --json" })
45
+ bash({ "cmd": "iurio cases search 'foo'" })
46
+ ```
47
+
48
+ Quote-heavy commands — wrap in single-quotes outside, escape inside:
49
+
50
+ ```
51
+ bash({ "cmd": "find ~/Downloads -name '*.pdf' -mtime -7 -exec ls -lh {} +" })
52
+ ```
53
+
54
+ Long-running command — use the `timeout_ms` param (default 5 min):
55
+
56
+ ```
57
+ bash({ "cmd": "pnpm test", "timeout_ms": 180000 })
58
+ ```
59
+
60
+ ## Anti-patterns
61
+
62
+ - **Don't** use `bash date` for the time — call `time.now` (in-process, no grant).
63
+ - **Don't** use `bash cat ~/notes.md` for a $HOME read — call `file.read` (no grant).
64
+ - **Don't** retry a denied/timed-out approval automatically — the owner saw the prompt and chose. Surface the timeout clearly and ask what they'd like to do.
65
+ - **Don't** chain destructive commands in one call (`rm -rf … && …`). One destructive command per approval so the owner can decide each.
66
+
67
+ ## Response shape
68
+
69
+ ```json
70
+ {
71
+ "stdout": "...",
72
+ "stderr": "...",
73
+ "exit_code": 0,
74
+ "timed_out": false
75
+ }
76
+ ```
77
+
78
+ Non-zero `exit_code` means the command failed — read `stderr` and decide whether to retry with a corrected command, surface the error to the user, or ask for guidance. **Don't** pretend it succeeded.
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: file
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]
5
+ ---
6
+
7
+ # Files in $HOME
8
+
9
+ ## Jail
10
+
11
+ Both tools are restricted to the agent's `$HOME` directory:
12
+
13
+ - `path` is resolved relative to `$HOME` (`~/` prefix accepted, or plain `notes.md`)
14
+ - `..` segments that would escape `$HOME` are rejected
15
+ - 1 MB cap per read/write — anything bigger gets truncated (read) or rejected (write)
16
+
17
+ For files **outside** `$HOME` (e.g. `/etc/...`, another user's directory), use `bash` with `cat`/`tee` — that goes through the DDISA grant cycle so the owner can approve broad-fs reads explicitly.
18
+
19
+ ## Patterns
20
+
21
+ Read a JSON config:
22
+
23
+ ```
24
+ file.read({ "path": "~/.openape/agent/agent.json" })
25
+ ```
26
+
27
+ Append a note (read-modify-write — there's no `file.append`):
28
+
29
+ ```
30
+ const r = file.read({ "path": "notes.md" })
31
+ file.write({ "path": "notes.md", "content": r.content + "\n- new line\n" })
32
+ ```
33
+
34
+ ## Conventions
35
+
36
+ - Paths in user prompts ("save this to notes.md") → relative to `$HOME`. Don't prefix with `/Users/...`.
37
+ - Binary files don't round-trip via these tools (UTF-8 only). Use `bash` for non-text I/O.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: http
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]
5
+ ---
6
+
7
+ # HTTP fetch
8
+
9
+ ## When to use
10
+
11
+ - `http.get` — read content from any HTTPS URL (webpages, REST endpoints, JSON APIs)
12
+ - `http.post` — POST JSON to an HTTPS URL
13
+
14
+ Both are bounded:
15
+
16
+ - Response capped at 1 MB (anything longer is truncated)
17
+ - Headers go through a deny-list (no `Authorization` for arbitrary hosts, no `Cookie`)
18
+ - HTTP-only URLs are rejected — HTTPS required
19
+
20
+ For commands that go beyond simple HTTP (auth, mTLS, complex curl flags, multipart upload), use the `bash` tool with `curl` instead.
21
+
22
+ ## Patterns
23
+
24
+ Fetch JSON:
25
+
26
+ ```
27
+ http.get({
28
+ "url": "https://api.example.com/users/42",
29
+ "headers": { "Accept": "application/json" }
30
+ })
31
+ ```
32
+
33
+ POST JSON:
34
+
35
+ ```
36
+ http.post({
37
+ "url": "https://api.example.com/notes",
38
+ "body": { "title": "from agent", "body": "..." },
39
+ "headers": { "Content-Type": "application/json" }
40
+ })
41
+ ```
42
+
43
+ ## Anti-patterns
44
+
45
+ - Don't synthesize URLs ("I think it's at /api/v1/foo") — ask the user for the exact endpoint or use `http.get` only after you've seen it in their message or in a tool result.
46
+ - Don't paginate by manually incrementing offsets without checking the API's actual contract — read response shape first.
47
+ - For auth that needs `Authorization: Bearer …`, the deny-list strips it. Use `bash` with `curl` if you need it.
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: mail
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]
5
+ ---
6
+
7
+ # Inbox (o365-cli)
8
+
9
+ ## What this is
10
+
11
+ Read access to the owner's Microsoft 365 inbox via the `o365-cli` tool on the agent host. The host must have `o365-cli` installed and authenticated (the owner's CLI session). If it isn't, both tools fail with a clear setup error.
12
+
13
+ ## When to use
14
+
15
+ - `mail.list` — recent inbox messages. Optional `unread_only: true`. Default limit 20.
16
+ - `mail.search` — keyword/from/subject search. Pass a query string.
17
+
18
+ For writing, archiving, replying, or moving messages: use `bash` with explicit `o365-cli` subcommands (see `o365-cli mail --help`). Those go through the DDISA grant cycle so the owner approves each mutation.
19
+
20
+ ## Patterns
21
+
22
+ Latest 10 unread:
23
+
24
+ ```
25
+ mail.list({ "limit": 10, "unread_only": true })
26
+ ```
27
+
28
+ Search by sender:
29
+
30
+ ```
31
+ mail.search({ "q": "from:smaurer@deloitte.at", "limit": 20 })
32
+ ```
33
+
34
+ ## Conventions
35
+
36
+ - Don't draft replies inside the agent if the user just asked "what's in my inbox" — listing is read-only, replies are explicit.
37
+ - When the user wants to triage ("welche kann ich archivieren?"), list first, then offer specific candidates with message IDs — do NOT auto-move anything.
38
+ - Account names: there are usually two — owner's primary email (Delta Mind) and a secondary (Legal Tech / DOCPIT). Ask which one if it matters.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: tasks
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]
5
+ ---
6
+
7
+ # Personal tasks (ape-tasks)
8
+
9
+ ## What this is
10
+
11
+ The owner's personal task list at https://tasks.openape.ai — same one their `ape-tasks` CLI on Mac and the iOS app use. You're allowed to read it and add to it via the `tasks.list` and `tasks.create` tools.
12
+
13
+ ## When to use
14
+
15
+ Use `tasks.create` whenever the user asks for any of:
16
+
17
+ - "Reminder me to X tomorrow"
18
+ - "Wiedervorlage in 2 Tagen"
19
+ - "Add to my todo: …"
20
+ - "Schedule X for next week"
21
+
22
+ Use `tasks.list` when they ask "what's on my list", "any open tasks", "remind me what's due".
23
+
24
+ ## Create — parameters
25
+
26
+ ```
27
+ tasks.create({
28
+ "title": "<short title>",
29
+ "notes": "<optional longer body>",
30
+ "priority": "low" | "med" | "high", // default med
31
+ "due_at": "<ISO 8601 or relative: +2h | +1d | tomorrow 9am>"
32
+ })
33
+ ```
34
+
35
+ For wiedervorlage (mail-eskalation) the owner has a separate flow via `ape-tasks new --remind-at ...` — that path triggers email reminders. If the user wants a *reminder* (not just a todo), prefer:
36
+
37
+ ```
38
+ bash({ "cmd": "ape-tasks new --title '...' --remind-at '+2d' --context-summary '...' --context-url '...'" })
39
+ ```
40
+
41
+ …because the CLI has more knobs than the tool exposes (assignee, remind-at, context-url).
42
+
43
+ ## Conventions
44
+
45
+ - Always convert relative dates from the user prompt to an absolute date in your response: "in 2 Tagen" → "am 13. Mai 2026".
46
+ - Don't create duplicate tasks — `tasks.list` first if the user might have one already.
47
+ - If the user asks for "remind me on …" and you only have `tasks.create`, set `due_at` and explain that the **list** will surface it, but no push notification fires unless they used `ape-tasks --remind-at`.
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: time
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]
5
+ ---
6
+
7
+ # Time and date
8
+
9
+ ## When to use
10
+
11
+ The `time.now` tool returns the current UTC timestamp as ISO 8601, plus the epoch in seconds and the agent host's timezone offset in minutes. Call it any time the user asks "what time is it", "what day", "how long ago was X", or as a quick "are you alive?" probe.
12
+
13
+ ## How to use
14
+
15
+ ```
16
+ time.now({})
17
+ ```
18
+
19
+ No arguments. Response shape:
20
+
21
+ ```json
22
+ {
23
+ "iso": "2026-05-11T07:14:44Z",
24
+ "epoch_seconds": 1778383484,
25
+ "timezone_offset_minutes": 120
26
+ }
27
+ ```
28
+
29
+ ## Conventions
30
+
31
+ - Always report time in **both** UTC and the user's local clock when the offset is non-zero. Example: "Es ist 07:14 UTC, also 09:14 bei dir (UTC+2)."
32
+ - For relative-time questions ("vor 3 Tagen"), compute from `epoch_seconds` — don't rely on the LLM's internal clock guess.
33
+ - Do NOT call `bash` with `date` for the time — `time.now` is in-process and skips the DDISA grant cycle.
package/dist/bridge.mjs CHANGED
@@ -1040,9 +1040,9 @@ var init_chunk_7OCVIDC7 = __esm({
1040
1040
  });
1041
1041
 
1042
1042
  // src/bridge.ts
1043
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
1044
- import { homedir as homedir8 } from "os";
1045
- import { join as join8 } from "path";
1043
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
1044
+ import { homedir as homedir9 } from "os";
1045
+ import { join as join9 } from "path";
1046
1046
  import process2 from "process";
1047
1047
 
1048
1048
  // ../../packages/cli-auth/dist/index.js
@@ -1422,12 +1422,94 @@ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as read
1422
1422
  import { homedir as homedir6 } from "os";
1423
1423
  import { join as join6 } from "path";
1424
1424
 
1425
- // ../../packages/apes/dist/chunk-TDSCDH5P.js
1425
+ // ../../packages/apes/dist/chunk-FRCNYDTR.js
1426
+ import { spawn } from "child_process";
1426
1427
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1427
1428
  import { homedir as homedir3 } from "os";
1428
1429
  import { dirname, normalize, resolve } from "path";
1429
1430
  import { execFileSync } from "child_process";
1430
1431
  import { execFileSync as execFileSync2 } from "child_process";
1432
+ var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
1433
+ var MAX_STDIO_BYTES = 64 * 1024;
1434
+ var BIN = "ape-shell";
1435
+ function capStdio(s2) {
1436
+ const buf = Buffer.from(s2, "utf8");
1437
+ if (buf.byteLength <= MAX_STDIO_BYTES) return s2;
1438
+ return `${buf.subarray(0, MAX_STDIO_BYTES).toString("utf8")}
1439
+ [truncated to ${MAX_STDIO_BYTES} bytes]`;
1440
+ }
1441
+ var bashTools = [
1442
+ {
1443
+ name: "bash",
1444
+ description: "Run a shell command on the agent host. Every invocation goes through the OpenApe DDISA grant cycle \u2014 auto-approved if the owner has a matching YOLO scope, otherwise the owner gets a push notification to approve. Runs as the agent's macOS user, so file/network access is limited to what that user can see. Returns stdout, stderr, and exit code. For repeated command patterns ask the owner to set up a YOLO scope so approvals don't pile up.",
1445
+ parameters: {
1446
+ type: "object",
1447
+ properties: {
1448
+ cmd: {
1449
+ type: "string",
1450
+ description: "Shell command to run, e.g. `ls -la ~/Documents`, `git status`, `curl -fsSL https://example.com`. The whole string is passed to `bash -c`; quote internally as needed."
1451
+ },
1452
+ timeout_ms: {
1453
+ type: "number",
1454
+ description: "Wall-clock cap for the whole approval-and-run cycle in milliseconds. Default 300000 (5 min). Approval waits count against this budget."
1455
+ }
1456
+ },
1457
+ required: ["cmd"]
1458
+ },
1459
+ execute: async (args) => {
1460
+ const a2 = args;
1461
+ if (typeof a2.cmd !== "string" || a2.cmd.trim() === "") {
1462
+ throw new Error("cmd must be a non-empty string");
1463
+ }
1464
+ const timeout = typeof a2.timeout_ms === "number" && a2.timeout_ms > 0 ? a2.timeout_ms : DEFAULT_TIMEOUT_MS;
1465
+ return await new Promise((resolveResult) => {
1466
+ const child = spawn(BIN, ["-c", a2.cmd], {
1467
+ env: { ...process.env, APE_WAIT: "1" },
1468
+ stdio: ["ignore", "pipe", "pipe"]
1469
+ });
1470
+ let stdout2 = "";
1471
+ let stderr = "";
1472
+ let timedOut = false;
1473
+ let spawnError = null;
1474
+ child.stdout.on("data", (chunk) => {
1475
+ stdout2 += chunk.toString("utf8");
1476
+ });
1477
+ child.stderr.on("data", (chunk) => {
1478
+ stderr += chunk.toString("utf8");
1479
+ });
1480
+ child.on("error", (err) => {
1481
+ spawnError = err;
1482
+ });
1483
+ const timer = setTimeout(() => {
1484
+ timedOut = true;
1485
+ child.kill("SIGTERM");
1486
+ setTimeout(() => {
1487
+ try {
1488
+ child.kill("SIGKILL");
1489
+ } catch {
1490
+ }
1491
+ }, 5e3);
1492
+ }, timeout);
1493
+ child.on("close", (code) => {
1494
+ clearTimeout(timer);
1495
+ if (spawnError) {
1496
+ resolveResult({
1497
+ error: spawnError.message,
1498
+ hint: `Could not exec '${BIN}'. The agent host needs @openape/apes installed globally so ape-shell is on PATH.`
1499
+ });
1500
+ return;
1501
+ }
1502
+ resolveResult({
1503
+ stdout: capStdio(stdout2),
1504
+ stderr: capStdio(stderr),
1505
+ exit_code: code ?? -1,
1506
+ ...timedOut ? { timed_out: true } : {}
1507
+ });
1508
+ });
1509
+ });
1510
+ }
1511
+ }
1512
+ ];
1431
1513
  var MAX_BYTES = 1024 * 1024;
1432
1514
  function jailPath(input) {
1433
1515
  if (typeof input !== "string" || input === "") {
@@ -1718,7 +1800,8 @@ var ALL_TOOLS = [
1718
1800
  ...httpTools,
1719
1801
  ...fileTools,
1720
1802
  ...tasksTools,
1721
- ...mailTools
1803
+ ...mailTools,
1804
+ ...bashTools
1722
1805
  ];
1723
1806
  var TOOLS = Object.fromEntries(
1724
1807
  ALL_TOOLS.map((t2) => [t2.name, t2])
@@ -3716,6 +3799,162 @@ function shouldAutoAccept(peerEmail, identity, allowlist) {
3716
3799
  return allowlist.has(peer);
3717
3800
  }
3718
3801
 
3802
+ // src/skills.ts
3803
+ import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync } from "fs";
3804
+ import { homedir as homedir8 } from "os";
3805
+ import { dirname as dirname2, join as join8, resolve as resolve2 } from "path";
3806
+ import { fileURLToPath } from "url";
3807
+ var SKILLS_SUBDIR = [".openape", "agent", "skills"];
3808
+ var SOUL_PATH_PARTS = [".openape", "agent", "SOUL.md"];
3809
+ function soulPath(home = homedir8()) {
3810
+ return join8(home, ...SOUL_PATH_PARTS);
3811
+ }
3812
+ function skillsDir(home = homedir8()) {
3813
+ return join8(home, ...SKILLS_SUBDIR);
3814
+ }
3815
+ function readSoul(home = homedir8()) {
3816
+ const path = soulPath(home);
3817
+ if (!existsSync6(path)) return null;
3818
+ try {
3819
+ const body = readFileSync7(path, "utf8").trim();
3820
+ return body.length > 0 ? body : null;
3821
+ } catch {
3822
+ return null;
3823
+ }
3824
+ }
3825
+ function parseFrontmatter(content) {
3826
+ const trimmed = content.trimStart();
3827
+ if (!trimmed.startsWith("---")) return null;
3828
+ const closeIdx = trimmed.indexOf("\n---", 3);
3829
+ if (closeIdx < 0) return null;
3830
+ 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;
3858
+ }
3859
+ fields[key] = value.trim().replace(/^["']|["']$/g, "");
3860
+ }
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 };
3865
+ }
3866
+ function scanSkillsDir(dir) {
3867
+ if (!existsSync6(dir)) return [];
3868
+ let entries;
3869
+ try {
3870
+ entries = readdirSync4(dir);
3871
+ } catch {
3872
+ return [];
3873
+ }
3874
+ const out = [];
3875
+ for (const entry of entries) {
3876
+ const skillPath = join8(dir, entry, "SKILL.md");
3877
+ if (!existsSync6(skillPath)) continue;
3878
+ let st;
3879
+ try {
3880
+ st = statSync(skillPath);
3881
+ } catch {
3882
+ continue;
3883
+ }
3884
+ if (!st.isFile()) continue;
3885
+ let body;
3886
+ try {
3887
+ body = readFileSync7(skillPath, "utf8");
3888
+ } catch {
3889
+ continue;
3890
+ }
3891
+ const fm = parseFrontmatter(body);
3892
+ if (!fm) continue;
3893
+ out.push({
3894
+ name: fm.name,
3895
+ description: fm.description,
3896
+ filePath: skillPath,
3897
+ requiresTools: fm.requiresTools
3898
+ });
3899
+ }
3900
+ return out;
3901
+ }
3902
+ function defaultSkillsDir() {
3903
+ const here = dirname2(fileURLToPath(import.meta.url));
3904
+ return resolve2(here, "..", "default-skills");
3905
+ }
3906
+ function composeSkills(home, enabledTools) {
3907
+ const enabled = new Set(enabledTools);
3908
+ const byName = /* @__PURE__ */ new Map();
3909
+ for (const s2 of scanSkillsDir(defaultSkillsDir())) byName.set(s2.name, s2);
3910
+ for (const s2 of scanSkillsDir(skillsDir(home))) byName.set(s2.name, s2);
3911
+ const out = [];
3912
+ for (const s2 of byName.values()) {
3913
+ if (s2.requiresTools && s2.requiresTools.length > 0) {
3914
+ const allPresent = s2.requiresTools.every((t2) => enabled.has(t2));
3915
+ if (!allPresent) continue;
3916
+ }
3917
+ out.push(s2);
3918
+ }
3919
+ out.sort((a2, b2) => a2.name.localeCompare(b2.name));
3920
+ return out;
3921
+ }
3922
+ function escapeXml(s2) {
3923
+ return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3924
+ }
3925
+ function formatSkillsBlock(skills) {
3926
+ if (skills.length === 0) return "";
3927
+ const lines = [
3928
+ "",
3929
+ "The following skills provide specialized instructions for specific tasks.",
3930
+ "Use the file.read tool to load a skill's file when the user's task matches its description.",
3931
+ "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md) and use that absolute path in tool commands.",
3932
+ "",
3933
+ "<available_skills>"
3934
+ ];
3935
+ for (const s2 of skills) {
3936
+ lines.push(" <skill>");
3937
+ lines.push(` <name>${escapeXml(s2.name)}</name>`);
3938
+ lines.push(` <description>${escapeXml(s2.description)}</description>`);
3939
+ lines.push(` <location>${escapeXml(s2.filePath)}</location>`);
3940
+ lines.push(" </skill>");
3941
+ }
3942
+ lines.push("</available_skills>");
3943
+ return lines.join("\n");
3944
+ }
3945
+ function composeSystemPrompt(input) {
3946
+ const home = input.home ?? homedir8();
3947
+ const parts = [];
3948
+ const soul = readSoul(home);
3949
+ if (soul) parts.push(soul);
3950
+ const skills = composeSkills(home, input.enabledTools);
3951
+ const skillsBlock = formatSkillsBlock(skills);
3952
+ if (skillsBlock) parts.push(skillsBlock);
3953
+ const base = input.base?.trim();
3954
+ if (base) parts.push(base);
3955
+ return parts.join("\n\n");
3956
+ }
3957
+
3719
3958
  // src/throttle.ts
3720
3959
  function createThrottle(fn, intervalMs) {
3721
3960
  let timer;
@@ -3868,20 +4107,20 @@ var ThreadSession = class {
3868
4107
  };
3869
4108
 
3870
4109
  // src/bridge.ts
3871
- var AGENT_CONFIG_PATH2 = join8(homedir8(), ".openape", "agent", "agent.json");
4110
+ var AGENT_CONFIG_PATH2 = join9(homedir9(), ".openape", "agent", "agent.json");
3872
4111
  function resolveSystemPrompt(envFallback) {
3873
- if (!existsSync6(AGENT_CONFIG_PATH2)) return envFallback;
4112
+ if (!existsSync7(AGENT_CONFIG_PATH2)) return envFallback;
3874
4113
  try {
3875
- const parsed = JSON.parse(readFileSync7(AGENT_CONFIG_PATH2, "utf8"));
4114
+ const parsed = JSON.parse(readFileSync8(AGENT_CONFIG_PATH2, "utf8"));
3876
4115
  return typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : envFallback;
3877
4116
  } catch {
3878
4117
  return envFallback;
3879
4118
  }
3880
4119
  }
3881
4120
  function resolveTools(envFallback) {
3882
- if (existsSync6(AGENT_CONFIG_PATH2)) {
4121
+ if (existsSync7(AGENT_CONFIG_PATH2)) {
3883
4122
  try {
3884
- const parsed = JSON.parse(readFileSync7(AGENT_CONFIG_PATH2, "utf8"));
4123
+ const parsed = JSON.parse(readFileSync8(AGENT_CONFIG_PATH2, "utf8"));
3885
4124
  if (Array.isArray(parsed.tools)) {
3886
4125
  return parsed.tools.filter((t2) => typeof t2 === "string");
3887
4126
  }
@@ -3899,10 +4138,10 @@ var RECONNECT_BASE_MS = 1e3;
3899
4138
  var RECONNECT_MAX_MS = 3e4;
3900
4139
  var ALLOWLIST_POLL_INTERVAL_MS = 3e4;
3901
4140
  function loadBridgeEnvFile() {
3902
- const path = join8(homedir8(), "Library", "Application Support", "openape", "bridge", ".env");
3903
- if (!existsSync6(path)) return;
4141
+ const path = join9(homedir9(), "Library", "Application Support", "openape", "bridge", ".env");
4142
+ if (!existsSync7(path)) return;
3904
4143
  try {
3905
- const raw = readFileSync7(path, "utf8");
4144
+ const raw = readFileSync8(path, "utf8");
3906
4145
  for (const line of raw.split(/\r?\n/)) {
3907
4146
  const trimmed = line.trim();
3908
4147
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -3953,7 +4192,7 @@ function log(line) {
3953
4192
  `);
3954
4193
  }
3955
4194
  function sleep(ms) {
3956
- return new Promise((resolve2) => setTimeout(resolve2, ms));
4195
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
3957
4196
  }
3958
4197
  function truncate(s2, n2) {
3959
4198
  return s2.length <= n2 ? s2 : `${s2.slice(0, n2 - 1)}\u2026`;
@@ -4056,11 +4295,16 @@ var Bridge = class {
4056
4295
  threadId,
4057
4296
  chat: this.chat,
4058
4297
  runtimeConfig: this.runtimeConfig(),
4059
- systemPrompt: resolveSystemPrompt(this.cfg.systemPrompt),
4060
4298
  // Tools resolve from agent.json (latest sync from troop) on
4061
4299
  // every new thread, so owner edits in the troop UI take
4062
4300
  // effect after the next sync without a bridge restart.
4301
+ // SOUL.md + skills are merged into the system prompt the same
4302
+ // way — picked up per-thread without restart.
4063
4303
  tools: resolveTools(this.cfg.tools),
4304
+ systemPrompt: composeSystemPrompt({
4305
+ base: resolveSystemPrompt(this.cfg.systemPrompt),
4306
+ enabledTools: resolveTools(this.cfg.tools)
4307
+ }),
4064
4308
  maxSteps: this.cfg.maxSteps,
4065
4309
  log
4066
4310
  });
@@ -4071,7 +4315,7 @@ var Bridge = class {
4071
4315
  const bearer = await this.bearer();
4072
4316
  const wsUrl = `${this.cfg.endpoint.replace(/^http/, "ws")}/api/ws?token=${encodeURIComponent(bearer.replace(/^Bearer\s+/i, ""))}`;
4073
4317
  const ws = new WebSocket(wsUrl);
4074
- return new Promise((resolve2, reject) => {
4318
+ return new Promise((resolve3, reject) => {
4075
4319
  let pingTimer;
4076
4320
  let allowlistTimer;
4077
4321
  ws.on("open", () => {
@@ -4104,7 +4348,7 @@ var Bridge = class {
4104
4348
  ws.on("close", () => {
4105
4349
  if (pingTimer) clearInterval(pingTimer);
4106
4350
  if (allowlistTimer) clearInterval(allowlistTimer);
4107
- resolve2();
4351
+ resolve3();
4108
4352
  });
4109
4353
  ws.on("error", (err) => {
4110
4354
  if (pingTimer) clearInterval(pingTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openape/ape-agent",
3
- "version": "2.0.1",
3
+ "version": "2.1.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",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "dist",
14
+ "default-skills",
14
15
  "README.md"
15
16
  ],
16
17
  "publishConfig": {
@@ -20,7 +21,7 @@
20
21
  "jose": "^5.9.0",
21
22
  "ofetch": "^1.4.1",
22
23
  "ws": "^8.18.0",
23
- "@openape/apes": "1.20.0",
24
+ "@openape/apes": "1.22.0",
24
25
  "@openape/cli-auth": "0.4.0"
25
26
  },
26
27
  "devDependencies": {