@desplega.ai/agent-swarm 1.87.0 → 1.88.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.
Files changed (59) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +13 -1
  3. package/package.json +5 -5
  4. package/src/be/db.ts +49 -7
  5. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  6. package/src/be/modelsdev-cache.json +1123 -1034
  7. package/src/be/seed/registry.ts +3 -2
  8. package/src/be/seed-skills/index.ts +172 -0
  9. package/src/cli.tsx +33 -4
  10. package/src/commands/e2b-stack-wizard.tsx +394 -0
  11. package/src/commands/e2b.ts +1352 -53
  12. package/src/commands/onboard/dashboard-url.ts +29 -0
  13. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  14. package/src/commands/onboard.tsx +3 -1
  15. package/src/commands/runner.ts +1 -0
  16. package/src/e2b/dispatch.ts +234 -18
  17. package/src/http/memory.ts +13 -1
  18. package/src/http/skills.ts +53 -0
  19. package/src/http/webhooks.ts +75 -0
  20. package/src/integrations/kapso/client.ts +82 -0
  21. package/src/memory/automatic-task-gate.ts +47 -0
  22. package/src/prompts/base-prompt.ts +16 -1
  23. package/src/prompts/session-templates.ts +51 -0
  24. package/src/providers/claude-adapter.ts +19 -0
  25. package/src/providers/codex-adapter.ts +22 -0
  26. package/src/providers/ctx-mode-env.ts +10 -0
  27. package/src/providers/opencode-adapter.ts +50 -1
  28. package/src/slack/blocks.ts +12 -4
  29. package/src/slack/watcher.ts +3 -3
  30. package/src/telemetry.ts +14 -1
  31. package/src/templates.d.ts +4 -0
  32. package/src/tests/base-prompt.test.ts +41 -0
  33. package/src/tests/claude-adapter.test.ts +86 -1
  34. package/src/tests/codex-adapter.test.ts +89 -0
  35. package/src/tests/e2b-dispatch.test.ts +603 -11
  36. package/src/tests/http-api-integration.test.ts +113 -0
  37. package/src/tests/kapso-client.test.ts +74 -1
  38. package/src/tests/kapso-inbound.test.ts +60 -2
  39. package/src/tests/opencode-adapter.test.ts +95 -0
  40. package/src/tests/prompt-template-session.test.ts +4 -2
  41. package/src/tests/self-improvement.test.ts +89 -0
  42. package/src/tests/skill-update-scope.test.ts +88 -1
  43. package/src/tests/slack-blocks.test.ts +15 -0
  44. package/src/tests/system-default-skills.test.ts +119 -0
  45. package/src/tests/telemetry-init.test.ts +86 -0
  46. package/src/tools/skills/skill-delete.ts +14 -0
  47. package/src/tools/skills/skill-update.ts +14 -0
  48. package/src/tools/store-progress.ts +19 -5
  49. package/src/types.ts +1 -0
  50. package/templates/skills/artifacts/config.json +1 -0
  51. package/templates/skills/kv-storage/config.json +1 -0
  52. package/templates/skills/pages/config.json +1 -0
  53. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  54. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  55. package/templates/skills/swarm-scripts/config.json +14 -0
  56. package/templates/skills/swarm-scripts/content.md +86 -0
  57. package/templates/skills/workflow-iterate/config.json +1 -0
  58. package/templates/skills/workflow-structured-output/config.json +1 -0
  59. package/tsconfig.json +2 -1
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Build the swarm-dashboard deep-link the SPA reads after a local onboard.
3
+ *
4
+ * IMPORTANT: the dashboard SPA reads **camelCase** `apiUrl` / `apiKey` query
5
+ * params (see ui/src/hooks/use-config.ts → extractUrlParams) and silently
6
+ * ignores snake_case. An earlier version of these builders emitted snake_case
7
+ * `api_url` / `api_key`, so the auto-connect deep-link never worked. Keep these
8
+ * camelCase.
9
+ */
10
+
11
+ const DEFAULT_DASHBOARD_BASE = "https://app.agent-swarm.dev";
12
+
13
+ export type DashboardUrlParts = {
14
+ apiUrl: string;
15
+ apiKey?: string;
16
+ /** Optional connection name shown in the dashboard (camelCase `name`). */
17
+ name?: string;
18
+ /** Override the dashboard base (defaults to the production app). */
19
+ base?: string;
20
+ };
21
+
22
+ export function buildOnboardDashboardUrl(parts: DashboardUrlParts): string {
23
+ const params = new URLSearchParams();
24
+ params.set("apiUrl", parts.apiUrl);
25
+ if (parts.apiKey) params.set("apiKey", parts.apiKey);
26
+ if (parts.name) params.set("name", parts.name);
27
+ const base = (parts.base ?? DEFAULT_DASHBOARD_BASE).replace(/\/+$/, "");
28
+ return `${base}?${params.toString()}`;
29
+ }
@@ -1,10 +1,12 @@
1
1
  import { Select } from "@inkjs/ui";
2
2
  import { Box, Text } from "ink";
3
+ import { buildOnboardDashboardUrl } from "../dashboard-url.ts";
3
4
  import type { StepProps } from "../types.ts";
4
5
 
5
6
  export function PostDashboardStep({ state, addLog, goToNext }: StepProps) {
6
7
  const apiUrl = `http://localhost:${state.apiPort || 3013}`;
7
- const dashboardUrl = `https://app.agent-swarm.dev?api_url=${apiUrl}&api_key=${state.apiKey}`;
8
+ // camelCase params — the SPA ignores snake_case (see dashboard-url.ts).
9
+ const dashboardUrl = buildOnboardDashboardUrl({ apiUrl, apiKey: state.apiKey });
8
10
 
9
11
  return (
10
12
  <Box flexDirection="column" padding={1}>
@@ -4,6 +4,7 @@ import { Box, Text, useApp, useInput } from "ink";
4
4
  import { useCallback, useEffect, useRef, useState } from "react";
5
5
  import pkg from "../../package.json";
6
6
  import { getApiKey } from "../utils/api-key.ts";
7
+ import { buildOnboardDashboardUrl } from "./onboard/dashboard-url.ts";
7
8
  import { getAgentSummary, getPresetById, PRESETS } from "./onboard/presets.ts";
8
9
  import { CoreCredentialsStep } from "./onboard/steps/core-credentials.tsx";
9
10
  import { CustomTemplatesStep } from "./onboard/steps/custom-templates.tsx";
@@ -244,7 +245,8 @@ export function Onboard({ dryRun = false, yes = false, preset }: OnboardProps) {
244
245
 
245
246
  if (state.step === "done") {
246
247
  const apiUrl = `http://localhost:${state.apiPort || 3013}`;
247
- const dashUrl = `https://app.agent-swarm.dev?api_url=${apiUrl}&api_key=${state.apiKey}`;
248
+ // camelCase params — the SPA ignores snake_case (see dashboard-url.ts).
249
+ const dashUrl = buildOnboardDashboardUrl({ apiUrl, apiKey: state.apiKey });
248
250
  const agentCount = state.services.reduce((sum, s) => sum + s.count, 0);
249
251
 
250
252
  return (
@@ -3295,6 +3295,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3295
3295
  swarmUrl,
3296
3296
  capabilities,
3297
3297
  traits,
3298
+ provider: adapter.name as ProviderName,
3298
3299
  name: agentProfileName,
3299
3300
  description: agentDescription,
3300
3301
  ...(traits.hasLocalEnvironment && {
@@ -14,6 +14,26 @@ export type E2BSandboxInfo = {
14
14
  startedAt?: string;
15
15
  endAt?: string;
16
16
  metadata?: Record<string, string>;
17
+ // Client-side fallback for the sandbox expiry. The raw `POST /sandboxes`
18
+ // create response uses E2B's `Sandbox` schema, which (unlike `ListedSandbox`
19
+ // / `SandboxDetail`) does NOT include `endAt`. We populate this from
20
+ // `now + timeoutSec*1000` at create time so `ttlRemaining` can report expiry
21
+ // immediately after a launch without an extra round-trip. `endAt` (when
22
+ // present, e.g. from `listSandboxes`) is always authoritative over this.
23
+ expiresAt?: string;
24
+ };
25
+
26
+ export type TtlRemaining = {
27
+ expiresAt?: string;
28
+ secondsLeft?: number;
29
+ };
30
+
31
+ export type SetSandboxTimeoutOptions = {
32
+ sandboxId: string;
33
+ apiKey: string;
34
+ apiBase?: string;
35
+ e2bEnv?: EnvMap;
36
+ timeoutMs: number;
17
37
  };
18
38
 
19
39
  export type E2BCommandResult = {
@@ -80,6 +100,25 @@ export type StartDetachedOptions = {
80
100
  cwd?: string;
81
101
  };
82
102
 
103
+ export type StreamSandboxLogOptions = {
104
+ sandboxId: string;
105
+ role: E2BRole;
106
+ apiKey: string;
107
+ apiBase?: string;
108
+ e2bEnv?: EnvMap;
109
+ /** Number of trailing history lines to emit before following (default 200). */
110
+ tailLines?: number;
111
+ /** When true, keep streaming new output (`tail -f`) until the caller aborts. */
112
+ follow?: boolean;
113
+ /**
114
+ * Egress sink for each chunk. The caller MUST scrub here — log output is
115
+ * untrusted entrypoint stdout and can embed tokens/secrets.
116
+ */
117
+ onChunk: (chunk: string) => void;
118
+ /** Abort signal to stop a `--follow` stream (e.g. on SIGINT). */
119
+ signal?: AbortSignal;
120
+ };
121
+
83
122
  type E2BSdkConnectionOptions = {
84
123
  apiKey: string;
85
124
  apiUrl?: string;
@@ -98,15 +137,24 @@ function e2bHeaders(apiKey: string): Record<string, string> {
98
137
  };
99
138
  }
100
139
 
101
- export function buildDetachedShell(command: string, logPath: string, pidPath: string): string {
102
- return [
103
- "set -e",
104
- `nohup ${command} >${logPath} 2>&1 </dev/null & pid=$!`,
105
- "sleep 2",
106
- `if ! kill -0 "$pid" 2>/dev/null; then cat ${logPath} >&2; exit 1; fi`,
107
- `echo "$pid" > ${pidPath}`,
108
- 'echo "$pid"',
109
- ].join("; ");
140
+ /**
141
+ * Build the shell payload for the envd-tracked entrypoint launch. Phase 5: the
142
+ * entrypoint is no longer detached via `nohup … >file &` (a grandchild envd
143
+ * never sees). Instead it runs as the SDK background command itself (envd owns
144
+ * and streams it; it survives client disconnect). We still `tee` to a
145
+ * deterministic file so `swarms logs` can retrieve FULL history later: the SDK's
146
+ * `commands.connect(pid)` only streams output going forward from the connect
147
+ * instant — it does NOT replay stdout produced while disconnected (verified
148
+ * against the e2b SDK types + docs) — so the file copy is the only reliable
149
+ * full-history source.
150
+ *
151
+ * `set -o pipefail` makes the pipeline's exit code reflect the ENTRYPOINT rather
152
+ * than `tee` (tee exits 0 on EOF even if the entrypoint crashed), so the early
153
+ * `exitCode` poll in {@link startDetachedProcess} can detect a launch failure.
154
+ * Invoked via `bash -lc` (both the api + worker images ship bash) for pipefail.
155
+ */
156
+ export function buildTrackedShell(command: string, logPath: string): string {
157
+ return `set -o pipefail; ${command} 2>&1 | tee ${logPath}`;
110
158
  }
111
159
 
112
160
  export function e2bSdkConnectionOptions(
@@ -184,7 +232,10 @@ export async function e2bFetchJson<T>(
184
232
  }
185
233
 
186
234
  export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSandboxInfo> {
187
- return e2bFetchJson<E2BSandboxInfo>(
235
+ // Capture the wall-clock create instant BEFORE the request so the client-side
236
+ // expiry fallback reflects when the TTL countdown begins.
237
+ const createdAt = Date.now();
238
+ const sandbox = await e2bFetchJson<E2BSandboxInfo>(
188
239
  "/sandboxes",
189
240
  opts.apiKey,
190
241
  {
@@ -200,6 +251,61 @@ export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSand
200
251
  },
201
252
  opts.apiBase,
202
253
  );
254
+ // Pre-flight check (resolved against node_modules/e2b types): the create
255
+ // response is E2B's `Sandbox` schema, which omits `endAt`. Compute a
256
+ // client-side expiry fallback so `ttlRemaining` works right after launch.
257
+ if (!sandbox.endAt && !sandbox.expiresAt) {
258
+ sandbox.expiresAt = new Date(createdAt + opts.timeoutSec * 1000).toISOString();
259
+ }
260
+ return sandbox;
261
+ }
262
+
263
+ /**
264
+ * Compute the remaining time-to-live for a sandbox. Prefers the authoritative
265
+ * `endAt` (present on listed/detail responses); falls back to the client-side
266
+ * `expiresAt` stamped by `createSandbox`. Returns an empty object when neither
267
+ * is available (e.g. a dry-run fake sandbox). `secondsLeft` is clamped at 0 so
268
+ * an already-expired sandbox never reports negative time.
269
+ */
270
+ export function ttlRemaining(sandbox: E2BSandboxInfo): TtlRemaining {
271
+ const expiresAt = sandbox.endAt ?? sandbox.expiresAt;
272
+ if (!expiresAt) return {};
273
+ const expiryMs = Date.parse(expiresAt);
274
+ if (Number.isNaN(expiryMs)) return {};
275
+ const secondsLeft = Math.max(0, Math.round((expiryMs - Date.now()) / 1000));
276
+ return { expiresAt, secondsLeft };
277
+ }
278
+
279
+ /**
280
+ * Extend (or reduce) a live sandbox's TTL via the SDK and read back the actual
281
+ * `endAt` E2B applied (the server clamps to the tier max, so the requested
282
+ * timeout is not always honored verbatim). Connecting to a dead/expired sandbox
283
+ * throws; we translate that into a redacted "not found / already expired"
284
+ * error so a stale sandbox ID never leaks the controller key into logs.
285
+ */
286
+ export async function setSandboxTimeout(opts: SetSandboxTimeoutOptions): Promise<TtlRemaining> {
287
+ const { Sandbox } = await import("e2b");
288
+ let sandbox: Awaited<ReturnType<typeof Sandbox.connect>>;
289
+ try {
290
+ sandbox = await Sandbox.connect(
291
+ opts.sandboxId,
292
+ e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
293
+ );
294
+ } catch {
295
+ // Do not surface the underlying error verbatim — it can embed the
296
+ // controller API key / connection URL. Emit a fixed redacted message.
297
+ throw new Error(`sandbox ${opts.sandboxId} not found / already expired`);
298
+ }
299
+
300
+ await sandbox.setTimeout(opts.timeoutMs);
301
+ // `setTimeout` returns void; re-read the info to learn the clamped expiry.
302
+ const info = await sandbox.getInfo();
303
+ const expiresAt = info.endAt instanceof Date ? info.endAt.toISOString() : String(info.endAt);
304
+ return ttlRemaining({
305
+ sandboxID: opts.sandboxId,
306
+ templateID: info.templateId,
307
+ endAt: expiresAt,
308
+ });
203
309
  }
204
310
 
205
311
  export async function killSandbox(
@@ -222,27 +328,137 @@ export async function listSandboxes(
222
328
  return e2bFetchJson<E2BSandboxInfo[]>("/sandboxes", apiKey, {}, apiBase);
223
329
  }
224
330
 
331
+ /**
332
+ * The deterministic per-role log path the entrypoint tees to. `swarms logs`
333
+ * recomputes it from the role alone (no PID bookkeeping needed) to `tail`/`cat`
334
+ * full history or `tail -f` for `--follow`.
335
+ */
336
+ export function sandboxLogPath(role: E2BRole): string {
337
+ return `/tmp/agent-swarm-e2b-${role}.log`;
338
+ }
339
+
340
+ /**
341
+ * Launch the entrypoint as an envd-tracked BACKGROUND command (Phase 5). Returns
342
+ * the PID immediately. Replaces the old `nohup … >file & sleep 2; kill -0` hack:
343
+ * the SDK's background handle exposes `exitCode` (undefined while running), so we
344
+ * poll it once after a short grace period — a non-zero exit by then means the
345
+ * entrypoint died at launch, which we surface as a launch failure (reading the
346
+ * tee'd log for context). The `tee` preserves a file copy for full-history
347
+ * retrieval regardless of envd stdout-replay semantics.
348
+ */
225
349
  export async function startDetachedProcess(opts: StartDetachedOptions): Promise<string> {
226
- const logPath = `/tmp/agent-swarm-e2b-${opts.role}.log`;
227
- const pidPath = `/tmp/agent-swarm-e2b-${opts.role}.pid`;
228
- const shell = buildDetachedShell(opts.command, logPath, pidPath);
350
+ const logPath = sandboxLogPath(opts.role);
351
+ const shell = buildTrackedShell(opts.command, logPath);
229
352
 
230
353
  const { Sandbox } = await import("e2b");
231
354
  const sandbox = await Sandbox.connect(
232
355
  opts.sandbox.sandboxID,
233
356
  e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
234
357
  );
235
- const result = await sandbox.commands.run(shell, {
358
+ // `bash -lc` (not `sh`) so `set -o pipefail` is honored on both images.
359
+ const handle = await sandbox.commands.run(`bash -lc ${shellQuote(shell)}`, {
236
360
  user: opts.user ?? "root",
237
361
  cwd: opts.cwd ?? "/",
238
362
  envs: opts.env,
239
- timeoutMs: 30_000,
363
+ background: true,
240
364
  });
241
365
 
242
- if (result.exitCode !== 0) {
243
- throw new Error(`E2B start command failed: ${redactWithEnv(result.stderr, opts.env)}`);
366
+ // Early liveness poll: give the entrypoint a moment to fault, then check the
367
+ // handle's exit code. `undefined` = still running (the expected happy path).
368
+ await Bun.sleep(2_000);
369
+ if (typeof handle.exitCode === "number" && handle.exitCode !== 0) {
370
+ // The pipeline already exited non-zero — surface stderr/stdout (redacted, as
371
+ // entrypoint output can embed tokens) as a launch failure.
372
+ const detail = redactWithEnv(`${handle.stdout}\n${handle.stderr}`.trim(), opts.env);
373
+ throw new Error(`E2B start command exited ${handle.exitCode} at launch: ${detail}`);
374
+ }
375
+
376
+ return String(handle.pid);
377
+ }
378
+
379
+ /** Single-quote a string for safe embedding in a `bash -lc '<...>'` invocation. */
380
+ function shellQuote(value: string): string {
381
+ return `'${value.split("'").join(`'\\''`)}'`;
382
+ }
383
+
384
+ /**
385
+ * Stream a sandbox's tee'd entrypoint log to the caller's `onChunk` sink.
386
+ *
387
+ * Design (Phase 5): we read from the deterministic per-role {@link sandboxLogPath}
388
+ * the entrypoint tees to — NOT from a tracked PID — so no PID bookkeeping is
389
+ * needed and history survives reconnect / a fresh CLI process. The SDK's
390
+ * `commands.connect(pid)` only streams forward from connect (no historical
391
+ * replay, verified against the SDK), so the file is the source of truth for
392
+ * full history.
393
+ *
394
+ * - History (no `--follow`): `tail -n <N> <logPath>` once (a CommandResult).
395
+ * - Follow: `tail -n <N> -F <logPath>` as a BACKGROUND command, piping each
396
+ * `onStdout`/`onStderr` chunk to `onChunk` until the abort signal fires
397
+ * (`-F` keeps following across truncation/rotation; tolerates a not-yet-created
398
+ * file). The caller scrubs inside `onChunk`.
399
+ *
400
+ * Output is emitted RAW here; the caller is responsible for scrubbing in
401
+ * `onChunk` (it sees both this function's stdout and stderr).
402
+ */
403
+ export async function streamSandboxLog(opts: StreamSandboxLogOptions): Promise<void> {
404
+ const logPath = sandboxLogPath(opts.role);
405
+ const tailLines = opts.tailLines ?? 200;
406
+
407
+ const { Sandbox } = await import("e2b");
408
+ const sandbox = await Sandbox.connect(
409
+ opts.sandboxId,
410
+ e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
411
+ );
412
+
413
+ if (!opts.follow) {
414
+ // History only: a single `tail`. If the file does not exist yet (entrypoint
415
+ // hasn't written), `tail` exits non-zero with a message on stderr — we emit
416
+ // that to the sink rather than throwing, so a freshly-launched swarm reads as
417
+ // "no logs yet" instead of a hard error.
418
+ const result = await sandbox.commands.run(
419
+ `bash -lc ${shellQuote(`tail -n ${tailLines} ${logPath} 2>&1 || true`)}`,
420
+ { user: "root", timeoutMs: 30_000 },
421
+ );
422
+ if (result.stdout) opts.onChunk(result.stdout);
423
+ if (result.stderr) opts.onChunk(result.stderr);
424
+ return;
425
+ }
426
+
427
+ // Follow: background `tail -F` streaming forward. `-F` (vs `-f`) re-opens the
428
+ // file if it is rotated/recreated and waits for a not-yet-existing file.
429
+ const handle = await sandbox.commands.run(
430
+ `bash -lc ${shellQuote(`tail -n ${tailLines} -F ${logPath}`)}`,
431
+ {
432
+ user: "root",
433
+ background: true,
434
+ onStdout: (data) => opts.onChunk(data),
435
+ onStderr: (data) => opts.onChunk(data),
436
+ },
437
+ );
438
+
439
+ const stop = async () => {
440
+ try {
441
+ await handle.kill();
442
+ } catch {
443
+ // The command may already be gone (sandbox killed/expired); ignore.
444
+ }
445
+ };
446
+ if (opts.signal) {
447
+ if (opts.signal.aborted) {
448
+ await stop();
449
+ return;
450
+ }
451
+ opts.signal.addEventListener("abort", stop, { once: true });
452
+ }
453
+
454
+ try {
455
+ // `wait()` resolves when the stream ends (sandbox death) or the handle is
456
+ // killed by the abort listener above. `tail -F` otherwise runs indefinitely.
457
+ await handle.wait();
458
+ } catch {
459
+ // A kill / disconnect surfaces as a rejected wait — that is the expected exit
460
+ // path for `--follow`, not an error to propagate.
244
461
  }
245
- return result.stdout.trim();
246
462
  }
247
463
 
248
464
  export async function waitForAgentRegistration(
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { chunkContent } from "../be/chunking";
4
+ import { getTaskById } from "../be/db";
4
5
  import { getEmbeddingProvider, getMemoryStore } from "../be/memory";
5
6
  import { CANDIDATE_SET_MULTIPLIER } from "../be/memory/constants";
6
7
  import { listEdgesForAgent } from "../be/memory/edges-store";
@@ -13,6 +14,7 @@ import {
13
14
  } from "../be/memory/raters/types";
14
15
  import { rerank } from "../be/memory/reranker";
15
16
  import { getRetrievalsForAgent, hasRetrievalForTask } from "../be/memory/retrieval-store";
17
+ import { shouldPersistAutomaticTaskMemory } from "../memory/automatic-task-gate";
16
18
  import { AgentMemoryScopeSchema, AgentMemorySourceSchema } from "../types";
17
19
  import { route } from "./route-def";
18
20
  import { json, jsonError, parseQueryParams } from "./utils";
@@ -34,6 +36,7 @@ const indexMemory = route({
34
36
  sourceTaskId: z.string().uuid().optional(),
35
37
  sourcePath: z.string().optional(),
36
38
  tags: z.array(z.string()).optional(),
39
+ persistMemory: z.boolean().optional(),
37
40
  }),
38
41
  responses: {
39
42
  202: { description: "Content queued for embedding" },
@@ -249,7 +252,16 @@ export async function handleMemory(
249
252
  const parsed = await indexMemory.parse(req, res, pathSegments, new URLSearchParams());
250
253
  if (!parsed) return true;
251
254
 
252
- const { agentId, content, name, scope, source, sourceTaskId, sourcePath, tags } = parsed.body;
255
+ const { agentId, content, name, scope, source, sourceTaskId, sourcePath, tags, persistMemory } =
256
+ parsed.body;
257
+
258
+ if (source === "session_summary" && sourceTaskId) {
259
+ const sourceTask = getTaskById(sourceTaskId);
260
+ if (sourceTask && !shouldPersistAutomaticTaskMemory(sourceTask, persistMemory)) {
261
+ json(res, { queued: false, memoryIds: [], skipped: "automatic_task_memory_disabled" }, 202);
262
+ return true;
263
+ }
264
+ }
253
265
 
254
266
  // Chunk content and create memories
255
267
  const contentChunks = chunkContent(content);
@@ -16,6 +16,9 @@ import { computeAgentSkillsSignature, syncSkillsToFilesystem } from "../be/skill
16
16
  import { route } from "./route-def";
17
17
  import { json, jsonError } from "./utils";
18
18
 
19
+ const SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE =
20
+ "This skill is system-managed and cannot be edited from the UI; it is re-seeded on each start. Fork it under a new name to customize.";
21
+
19
22
  // ─── Route Definitions ───────────────────────────────────────────────────────
20
23
 
21
24
  const listSkillsRoute = route({
@@ -67,6 +70,7 @@ const createSkillRoute = route({
67
70
  type: z.string().optional(),
68
71
  scope: z.string().optional(),
69
72
  ownerAgentId: z.string().optional(),
73
+ systemDefault: z.boolean().optional(),
70
74
  }),
71
75
  responses: {
72
76
  201: { description: "Skill created" },
@@ -85,6 +89,7 @@ const updateSkillRoute = route({
85
89
  body: z.record(z.string(), z.unknown()),
86
90
  responses: {
87
91
  200: { description: "Skill updated" },
92
+ 403: { description: "System-managed skills cannot be edited" },
88
93
  404: { description: "Skill not found" },
89
94
  },
90
95
  });
@@ -99,6 +104,7 @@ const deleteSkillRoute = route({
99
104
  params: z.object({ id: z.string() }),
100
105
  responses: {
101
106
  200: { description: "Skill deleted" },
107
+ 403: { description: "System-managed skills cannot be deleted" },
102
108
  404: { description: "Skill not found" },
103
109
  },
104
110
  });
@@ -452,6 +458,7 @@ export async function handleSkills(
452
458
  agent: pm.agent,
453
459
  disableModelInvocation: pm.disableModelInvocation,
454
460
  userInvocable: pm.userInvocable,
461
+ systemDefault: parsed.body.systemDefault,
455
462
  });
456
463
  json(res, { skill }, 201);
457
464
  } catch (err) {
@@ -465,6 +472,42 @@ export async function handleSkills(
465
472
  const parsed = await updateSkillRoute.parse(req, res, pathSegments, queryParams);
466
473
  if (!parsed) return true;
467
474
 
475
+ const existing = getSkillById(parsed.params.id);
476
+ if (!existing) {
477
+ jsonError(res, "Skill not found", 404);
478
+ return true;
479
+ }
480
+
481
+ const protectedSystemDefaultFields = [
482
+ "content",
483
+ "name",
484
+ "description",
485
+ "type",
486
+ "scope",
487
+ "ownerAgentId",
488
+ "sourceUrl",
489
+ "sourceRepo",
490
+ "sourcePath",
491
+ "sourceBranch",
492
+ "sourceHash",
493
+ "isComplex",
494
+ "allowedTools",
495
+ "model",
496
+ "effort",
497
+ "context",
498
+ "agent",
499
+ "disableModelInvocation",
500
+ "userInvocable",
501
+ "systemDefault",
502
+ ];
503
+ if (
504
+ existing.systemDefault &&
505
+ protectedSystemDefaultFields.some((field) => Object.hasOwn(parsed.body, field))
506
+ ) {
507
+ jsonError(res, SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE, 403);
508
+ return true;
509
+ }
510
+
468
511
  const updates: Record<string, unknown> = {};
469
512
  for (const [key, value] of Object.entries(parsed.body)) {
470
513
  updates[key] = value;
@@ -498,6 +541,16 @@ export async function handleSkills(
498
541
  const parsed = await deleteSkillRoute.parse(req, res, pathSegments, queryParams);
499
542
  if (!parsed) return true;
500
543
 
544
+ const existing = getSkillById(parsed.params.id);
545
+ if (!existing) {
546
+ jsonError(res, "Skill not found", 404);
547
+ return true;
548
+ }
549
+ if (existing.systemDefault) {
550
+ jsonError(res, SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE, 403);
551
+ return true;
552
+ }
553
+
501
554
  const deleted = deleteSkill(parsed.params.id);
502
555
  if (!deleted) {
503
556
  jsonError(res, "Skill not found", 404);
@@ -41,7 +41,14 @@ import {
41
41
  isGitLabEnabled,
42
42
  verifyGitLabWebhook,
43
43
  } from "../gitlab";
44
+ import {
45
+ type KapsoMessageActionResult,
46
+ markKapsoMessageRead,
47
+ sendKapsoReaction,
48
+ } from "../integrations/kapso/client";
49
+ import type { KapsoConfig } from "../integrations/kapso/config";
44
50
  import { getKapsoConfig } from "../integrations/kapso/config";
51
+ import type { KapsoWebhookPayload } from "../integrations/kapso/inbound";
45
52
  import { routeKapsoInbound } from "../integrations/kapso/inbound";
46
53
  import { getExecutorRegistry } from "../workflows";
47
54
  import { workflowEventBus } from "../workflows/event-bus";
@@ -108,6 +115,72 @@ const kapsoWebhook = route({
108
115
 
109
116
  // ─── Handler ─────────────────────────────────────────────────────────────────
110
117
 
118
+ function logKapsoAckResult(action: string, result: KapsoMessageActionResult): void {
119
+ if (result.ok) return;
120
+ console.warn(
121
+ `[Kapso] Inbound acknowledgement ${action} failed: ${
122
+ result.errorMessage ?? `status ${result.status}`
123
+ }`,
124
+ );
125
+ }
126
+
127
+ async function acknowledgeKapsoInboundMessage(
128
+ payload: KapsoWebhookPayload,
129
+ config: KapsoConfig,
130
+ ): Promise<void> {
131
+ const message = payload.message;
132
+ const phoneNumberId = payload.phone_number_id;
133
+ const messageId = message?.id;
134
+ const to = message?.from ?? payload.conversation?.phone_number;
135
+
136
+ if (payload.test || message?.kapso?.direction !== "inbound" || !phoneNumberId || !messageId) {
137
+ return;
138
+ }
139
+
140
+ if (!config.apiKey) {
141
+ console.warn("[Kapso] Cannot acknowledge inbound message: KAPSO_API_KEY is not configured");
142
+ return;
143
+ }
144
+
145
+ const markRead = markKapsoMessageRead({
146
+ apiBaseUrl: config.apiBaseUrl,
147
+ apiKey: config.apiKey,
148
+ phoneNumberId,
149
+ messageId,
150
+ typingIndicatorType: "text",
151
+ });
152
+ const react =
153
+ to && to.length > 0
154
+ ? sendKapsoReaction({
155
+ apiBaseUrl: config.apiBaseUrl,
156
+ apiKey: config.apiKey,
157
+ phoneNumberId,
158
+ to,
159
+ messageId,
160
+ emoji: "👀",
161
+ })
162
+ : Promise.resolve<KapsoMessageActionResult>({
163
+ ok: false,
164
+ status: 0,
165
+ raw: null,
166
+ errorMessage: "missing sender phone",
167
+ });
168
+
169
+ const [readResult, reactionResult] = await Promise.allSettled([markRead, react]);
170
+ if (readResult.status === "fulfilled") {
171
+ logKapsoAckResult("mark-as-read/typing", readResult.value);
172
+ } else {
173
+ console.warn(
174
+ `[Kapso] Inbound acknowledgement mark-as-read/typing failed: ${readResult.reason}`,
175
+ );
176
+ }
177
+ if (reactionResult.status === "fulfilled") {
178
+ logKapsoAckResult("reaction", reactionResult.value);
179
+ } else {
180
+ console.warn(`[Kapso] Inbound acknowledgement reaction failed: ${reactionResult.reason}`);
181
+ }
182
+ }
183
+
111
184
  export async function handleWebhooks(
112
185
  req: IncomingMessage,
113
186
  res: ServerResponse,
@@ -494,6 +567,8 @@ export async function handleWebhooks(
494
567
  return true;
495
568
  }
496
569
 
570
+ void acknowledgeKapsoInboundMessage(payload, config);
571
+
497
572
  try {
498
573
  const routing = routeKapsoInbound(payload);
499
574
  switch (routing.kind) {