@desplega.ai/agent-swarm 1.79.0 → 1.79.2

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 (46) hide show
  1. package/README.md +2 -0
  2. package/openapi.json +559 -1
  3. package/package.json +4 -4
  4. package/plugin/skills/kv-storage/SKILL.md +168 -0
  5. package/plugin/skills/pages/SKILL.md +149 -0
  6. package/src/artifact-sdk/browser-sdk.ts +292 -0
  7. package/src/be/db.ts +309 -0
  8. package/src/be/migrations/061_kv_store.sql +34 -0
  9. package/src/be/migrations/062_pages_view_count.sql +9 -0
  10. package/src/commands/provider-credentials.ts +1 -1
  11. package/src/http/index.ts +2 -0
  12. package/src/http/kv.ts +658 -0
  13. package/src/http/page-proxy.ts +5 -0
  14. package/src/http/pages-public.ts +50 -6
  15. package/src/http/status.ts +1 -1
  16. package/src/providers/claude-adapter.ts +138 -7
  17. package/src/providers/pi-mono-adapter.ts +3 -3
  18. package/src/providers/pi-mono-extension.ts +1 -1
  19. package/src/server.ts +20 -1
  20. package/src/tasks/context-key.ts +28 -0
  21. package/src/telemetry.ts +65 -1
  22. package/src/tests/claude-adapter-binary.test.ts +628 -0
  23. package/src/tests/context-key.test.ts +17 -0
  24. package/src/tests/kv-http.test.ts +331 -0
  25. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  26. package/src/tests/kv-page-proxy.test.ts +212 -0
  27. package/src/tests/kv-storage.test.ts +227 -0
  28. package/src/tests/kv-tool.test.ts +217 -0
  29. package/src/tests/page-proxy.test.ts +5 -1
  30. package/src/tests/page-session.test.ts +10 -5
  31. package/src/tests/pages-authed-mode.test.ts +5 -1
  32. package/src/tests/pages-public-html.test.ts +10 -1
  33. package/src/tests/pages-view-count.test.ts +220 -0
  34. package/src/tests/swarm-diff.test.ts +303 -0
  35. package/src/tests/telemetry-init.test.ts +149 -0
  36. package/src/tools/kv/index.ts +5 -0
  37. package/src/tools/kv/kv-delete.ts +89 -0
  38. package/src/tools/kv/kv-get.ts +64 -0
  39. package/src/tools/kv/kv-incr.ts +116 -0
  40. package/src/tools/kv/kv-list.ts +81 -0
  41. package/src/tools/kv/kv-set.ts +194 -0
  42. package/src/tools/kv/resolve-namespace.ts +58 -0
  43. package/src/tools/tool-config.ts +7 -0
  44. package/src/types.ts +53 -0
  45. package/src/utils/internal-ai/complete-structured.ts +7 -10
  46. package/src/utils/internal-ai/credentials.ts +3 -3
package/src/be/db.ts CHANGED
@@ -34,6 +34,8 @@ import type {
34
34
  InboxMessage,
35
35
  InboxMessageStatus,
36
36
  InputValue,
37
+ KvEntry,
38
+ KvValueType,
37
39
  McpServer,
38
40
  McpServerScope,
39
41
  McpServerTransport,
@@ -6264,6 +6266,7 @@ type PageRow = {
6264
6266
  needsCredentials: string | null;
6265
6267
  createdAt: string;
6266
6268
  updatedAt: string;
6269
+ view_count: number;
6267
6270
  };
6268
6271
 
6269
6272
  function rowToPage(row: PageRow): Page {
@@ -6280,6 +6283,7 @@ function rowToPage(row: PageRow): Page {
6280
6283
  needsCredentials: row.needsCredentials
6281
6284
  ? (JSON.parse(row.needsCredentials) as string[])
6282
6285
  : undefined,
6286
+ viewCount: typeof row.view_count === "number" ? row.view_count : 0,
6283
6287
  createdAt: normalizeDateRequired(row.createdAt),
6284
6288
  updatedAt: normalizeDateRequired(row.updatedAt),
6285
6289
  };
@@ -6422,6 +6426,19 @@ export function deletePage(id: string): boolean {
6422
6426
  return result.changes > 0;
6423
6427
  }
6424
6428
 
6429
+ /**
6430
+ * Bump the `view_count` counter on a page by 1. Called from `pages-public.ts`
6431
+ * on every successful 200 from `GET /p/:id` (HTML inline serve) and
6432
+ * `GET /p/:id.json` (JSON metadata fetch). No-op when the page doesn't
6433
+ * exist — caller already guards on `getPage(id)` before reaching the bump
6434
+ * path, so this only fires for valid ids. Wrapped in try/catch by the
6435
+ * caller so an unexpected DB error never breaks page serving.
6436
+ */
6437
+ export function incrementPageViewCount(id: string): boolean {
6438
+ const result = getDb().run("UPDATE pages SET view_count = view_count + 1 WHERE id = ?", [id]);
6439
+ return result.changes > 0;
6440
+ }
6441
+
6425
6442
  type PageVersionRow = {
6426
6443
  id: string;
6427
6444
  pageId: string;
@@ -9678,3 +9695,295 @@ export function hasFirstCompletedTask(): boolean {
9678
9695
  .get();
9679
9696
  return row !== null;
9680
9697
  }
9698
+
9699
+ // ============================================================================
9700
+ // KV store (kv_entries)
9701
+ // ============================================================================
9702
+ //
9703
+ // Namespaced key/value with lazy expire-on-read TTL. See:
9704
+ // - src/be/migrations/061_kv_store.sql (schema)
9705
+ // - src/http/kv.ts (REST surface + namespace resolution)
9706
+ // - src/tools/kv/* (MCP surface)
9707
+ //
9708
+ // Conventions:
9709
+ // - All sizing / regex validation happens at the HTTP / MCP boundary so the
9710
+ // helpers below can assume well-formed inputs.
9711
+ // - `value` is stored verbatim in TEXT; helpers decode based on value_type.
9712
+ // - "now" is `unixepoch('subsec') * 1000` (unix-ms), consistent with the
9713
+ // migration's DEFAULTs — using JS `Date.now()` for the few helpers that
9714
+ // need to mention an explicit timestamp keeps the math identical at ms
9715
+ // resolution.
9716
+
9717
+ interface KvRow {
9718
+ namespace: string;
9719
+ key: string;
9720
+ value: string;
9721
+ value_type: KvValueType;
9722
+ expires_at: number | null;
9723
+ created_at: number;
9724
+ updated_at: number;
9725
+ }
9726
+
9727
+ function decodeKvRow(row: KvRow): KvEntry {
9728
+ let value: unknown;
9729
+ if (row.value_type === "json") {
9730
+ try {
9731
+ value = JSON.parse(row.value);
9732
+ } catch {
9733
+ // Stored JSON is corrupt — surface as raw string rather than throwing
9734
+ // on read; the row is still recoverable by the caller.
9735
+ value = row.value;
9736
+ }
9737
+ } else if (row.value_type === "integer") {
9738
+ value = Number(row.value);
9739
+ } else {
9740
+ value = row.value;
9741
+ }
9742
+ return {
9743
+ namespace: row.namespace,
9744
+ key: row.key,
9745
+ value,
9746
+ valueType: row.value_type,
9747
+ expiresAt: row.expires_at,
9748
+ createdAt: row.created_at,
9749
+ updatedAt: row.updated_at,
9750
+ };
9751
+ }
9752
+
9753
+ function encodeKvValue(value: unknown, valueType: KvValueType): string {
9754
+ if (valueType === "json") {
9755
+ return JSON.stringify(value);
9756
+ }
9757
+ if (valueType === "integer") {
9758
+ if (typeof value === "number") {
9759
+ if (!Number.isInteger(value) || !Number.isSafeInteger(value)) {
9760
+ throw new Error("integer value must be a JS-safe integer");
9761
+ }
9762
+ return String(value);
9763
+ }
9764
+ if (typeof value === "string" && /^-?\d+$/.test(value)) {
9765
+ return value;
9766
+ }
9767
+ throw new Error("integer value must be a JS-safe integer");
9768
+ }
9769
+ // 'string'
9770
+ if (typeof value !== "string") {
9771
+ throw new Error("string value must be a string");
9772
+ }
9773
+ return value;
9774
+ }
9775
+
9776
+ /**
9777
+ * Get a single KV entry. Returns null if missing OR expired; expired rows are
9778
+ * deleted inline (single-row DELETE WHERE) so the row count stays bounded over
9779
+ * time without a background sweeper.
9780
+ */
9781
+ export function getKv(namespace: string, key: string): KvEntry | null {
9782
+ const row = getDb()
9783
+ .prepare<KvRow, [string, string]>(
9784
+ `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
9785
+ FROM kv_entries WHERE namespace = ? AND key = ?`,
9786
+ )
9787
+ .get(namespace, key);
9788
+ if (!row) return null;
9789
+ if (row.expires_at !== null && row.expires_at <= Date.now()) {
9790
+ getDb()
9791
+ .prepare<unknown, [string, string]>(`DELETE FROM kv_entries WHERE namespace = ? AND key = ?`)
9792
+ .run(namespace, key);
9793
+ return null;
9794
+ }
9795
+ return decodeKvRow(row);
9796
+ }
9797
+
9798
+ /**
9799
+ * Upsert a KV entry. Caller passes the decoded value + valueType; we encode
9800
+ * before storing. `expiresAt` is unix-ms (NULL means no expiry).
9801
+ *
9802
+ * If the key already exists with a different `valueType` we still overwrite —
9803
+ * INCR is the only collision-sensitive op and it does its own check.
9804
+ */
9805
+ export function upsertKv(input: {
9806
+ namespace: string;
9807
+ key: string;
9808
+ value: unknown;
9809
+ valueType: KvValueType;
9810
+ expiresAt?: number | null;
9811
+ }): KvEntry {
9812
+ const encoded = encodeKvValue(input.value, input.valueType);
9813
+ const expiresAt = input.expiresAt ?? null;
9814
+ const now = Date.now();
9815
+ const row = getDb()
9816
+ .prepare<KvRow, [string, string, string, KvValueType, number | null, number, number]>(
9817
+ `INSERT INTO kv_entries (namespace, key, value, value_type, expires_at, created_at, updated_at)
9818
+ VALUES (?, ?, ?, ?, ?, ?, ?)
9819
+ ON CONFLICT(namespace, key) DO UPDATE SET
9820
+ value = excluded.value,
9821
+ value_type = excluded.value_type,
9822
+ expires_at = excluded.expires_at,
9823
+ updated_at = excluded.updated_at
9824
+ RETURNING namespace, key, value, value_type, expires_at, created_at, updated_at`,
9825
+ )
9826
+ .get(input.namespace, input.key, encoded, input.valueType, expiresAt, now, now);
9827
+ if (!row) throw new Error("Failed to upsert kv entry");
9828
+ return decodeKvRow(row);
9829
+ }
9830
+
9831
+ /**
9832
+ * Delete a KV entry. Returns true if a row was removed, false if nothing
9833
+ * existed. Does not differentiate expired-but-not-yet-swept from never-existed.
9834
+ */
9835
+ export function deleteKv(namespace: string, key: string): boolean {
9836
+ const result = getDb()
9837
+ .prepare<unknown, [string, string]>(`DELETE FROM kv_entries WHERE namespace = ? AND key = ?`)
9838
+ .run(namespace, key);
9839
+ return result.changes > 0;
9840
+ }
9841
+
9842
+ export class KvTypeCollisionError extends Error {
9843
+ readonly existingType: KvValueType;
9844
+ constructor(existingType: KvValueType) {
9845
+ super(`Cannot INCR a key with value_type '${existingType}'`);
9846
+ this.name = "KvTypeCollisionError";
9847
+ this.existingType = existingType;
9848
+ }
9849
+ }
9850
+
9851
+ /**
9852
+ * Atomically increment an integer KV entry. Creates the entry (set to `by`)
9853
+ * if it doesn't exist or has expired. Throws `KvTypeCollisionError` if the
9854
+ * existing row's `value_type` is not 'integer' — the HTTP layer maps that to
9855
+ * 409.
9856
+ */
9857
+ export function incrKv(namespace: string, key: string, by: number): KvEntry {
9858
+ if (!Number.isInteger(by) || !Number.isSafeInteger(by)) {
9859
+ throw new Error("INCR `by` must be a JS-safe integer");
9860
+ }
9861
+ const database = getDb();
9862
+ return database.transaction((): KvEntry => {
9863
+ const existing = database
9864
+ .prepare<KvRow, [string, string]>(
9865
+ `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
9866
+ FROM kv_entries WHERE namespace = ? AND key = ?`,
9867
+ )
9868
+ .get(namespace, key);
9869
+
9870
+ const now = Date.now();
9871
+ const expired =
9872
+ existing?.expires_at !== null &&
9873
+ existing !== null &&
9874
+ existing.expires_at !== null &&
9875
+ existing.expires_at <= now;
9876
+
9877
+ if (!existing || expired) {
9878
+ // Insert (or replace if expired). `upsertKv` re-enters the prepared
9879
+ // statement cache cheaply; inlining keeps this in one transaction.
9880
+ const row = database
9881
+ .prepare<KvRow, [string, string, string, number | null, number, number]>(
9882
+ `INSERT INTO kv_entries (namespace, key, value, value_type, expires_at, created_at, updated_at)
9883
+ VALUES (?, ?, ?, 'integer', ?, ?, ?)
9884
+ ON CONFLICT(namespace, key) DO UPDATE SET
9885
+ value = excluded.value,
9886
+ value_type = excluded.value_type,
9887
+ expires_at = excluded.expires_at,
9888
+ updated_at = excluded.updated_at
9889
+ RETURNING namespace, key, value, value_type, expires_at, created_at, updated_at`,
9890
+ )
9891
+ .get(namespace, key, String(by), null, now, now);
9892
+ if (!row) throw new Error("Failed to insert kv entry on INCR");
9893
+ return decodeKvRow(row);
9894
+ }
9895
+
9896
+ if (existing.value_type !== "integer") {
9897
+ throw new KvTypeCollisionError(existing.value_type);
9898
+ }
9899
+
9900
+ const current = Number(existing.value);
9901
+ if (!Number.isSafeInteger(current)) {
9902
+ throw new Error("Stored integer KV value is not a JS-safe integer");
9903
+ }
9904
+ const next = current + by;
9905
+ if (!Number.isSafeInteger(next)) {
9906
+ throw new Error("INCR would overflow JS-safe integer range");
9907
+ }
9908
+
9909
+ const row = database
9910
+ .prepare<KvRow, [string, number, string, string]>(
9911
+ `UPDATE kv_entries SET value = ?, updated_at = ?
9912
+ WHERE namespace = ? AND key = ?
9913
+ RETURNING namespace, key, value, value_type, expires_at, created_at, updated_at`,
9914
+ )
9915
+ .get(String(next), now, namespace, key);
9916
+ if (!row) throw new Error("Failed to update kv entry on INCR");
9917
+ return decodeKvRow(row);
9918
+ })();
9919
+ }
9920
+
9921
+ /**
9922
+ * List entries in a namespace, optionally filtered by prefix. Expired rows
9923
+ * are filtered out by the SELECT (no inline DELETE — listing should be a
9924
+ * stable cursor; sweeping happens on point-reads instead).
9925
+ *
9926
+ * `limit` is capped by the caller (HTTP enforces ≤1000); helper does no extra
9927
+ * bounds-check beyond what SQL accepts.
9928
+ */
9929
+ export function listKv(
9930
+ namespace: string,
9931
+ opts: { prefix?: string; limit: number; offset: number },
9932
+ ): KvEntry[] {
9933
+ const now = Date.now();
9934
+ if (opts.prefix !== undefined && opts.prefix.length > 0) {
9935
+ // LIKE-escape `\` `%` `_` so a user-supplied prefix can't run wildcards.
9936
+ const escaped = opts.prefix.replace(/[\\%_]/g, "\\$&");
9937
+ const rows = getDb()
9938
+ .prepare<KvRow, [string, number, string, number, number]>(
9939
+ `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
9940
+ FROM kv_entries
9941
+ WHERE namespace = ?
9942
+ AND (expires_at IS NULL OR expires_at > ?)
9943
+ AND key LIKE ? ESCAPE '\\'
9944
+ ORDER BY key
9945
+ LIMIT ? OFFSET ?`,
9946
+ )
9947
+ .all(namespace, now, `${escaped}%`, opts.limit, opts.offset);
9948
+ return rows.map(decodeKvRow);
9949
+ }
9950
+ const rows = getDb()
9951
+ .prepare<KvRow, [string, number, number, number]>(
9952
+ `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
9953
+ FROM kv_entries
9954
+ WHERE namespace = ?
9955
+ AND (expires_at IS NULL OR expires_at > ?)
9956
+ ORDER BY key
9957
+ LIMIT ? OFFSET ?`,
9958
+ )
9959
+ .all(namespace, now, opts.limit, opts.offset);
9960
+ return rows.map(decodeKvRow);
9961
+ }
9962
+
9963
+ /**
9964
+ * Count entries in a namespace (optionally with a prefix filter). Expired
9965
+ * rows are excluded — same predicate as `listKv`.
9966
+ */
9967
+ export function countKv(namespace: string, opts: { prefix?: string }): number {
9968
+ const now = Date.now();
9969
+ if (opts.prefix !== undefined && opts.prefix.length > 0) {
9970
+ const escaped = opts.prefix.replace(/[\\%_]/g, "\\$&");
9971
+ const row = getDb()
9972
+ .prepare<{ n: number }, [string, number, string]>(
9973
+ `SELECT COUNT(*) AS n FROM kv_entries
9974
+ WHERE namespace = ?
9975
+ AND (expires_at IS NULL OR expires_at > ?)
9976
+ AND key LIKE ? ESCAPE '\\'`,
9977
+ )
9978
+ .get(namespace, now, `${escaped}%`);
9979
+ return row?.n ?? 0;
9980
+ }
9981
+ const row = getDb()
9982
+ .prepare<{ n: number }, [string, number]>(
9983
+ `SELECT COUNT(*) AS n FROM kv_entries
9984
+ WHERE namespace = ?
9985
+ AND (expires_at IS NULL OR expires_at > ?)`,
9986
+ )
9987
+ .get(namespace, now);
9988
+ return row?.n ?? 0;
9989
+ }
@@ -0,0 +1,34 @@
1
+ -- KV store: one table, namespaced. Namespace mirrors `agent_tasks.contextKey`
2
+ -- (task:slack:..., task:trackers:..., task:agent:..., task:page:..., etc.)
3
+ -- so the same string used to find sibling tasks for a Slack thread / PR /
4
+ -- Linear issue also indexes KV state for that entity.
5
+ --
6
+ -- value_type:
7
+ -- 'json' — `value` is the JSON-encoded payload (default; arbitrary shape).
8
+ -- 'string' — `value` is the raw UTF-8 string verbatim.
9
+ -- 'integer' — `value` is the decimal-string form of a JS-safe integer.
10
+ -- INCR uses this column; mixing with 'json'/'string' returns 409.
11
+ --
12
+ -- expires_at:
13
+ -- Unix-ms (matches `unixepoch('subsec') * 1000`). NULL means never expires.
14
+ -- Lazy expire on read: getKv DELETEs single expired rows; listKv filters in
15
+ -- the SELECT but does not delete (keeps cursors stable). No background sweep.
16
+ --
17
+ -- WITHOUT ROWID: every read is by full PK (namespace, key); the rowid -> btree
18
+ -- hop is wasted.
19
+
20
+ CREATE TABLE IF NOT EXISTS kv_entries (
21
+ namespace TEXT NOT NULL,
22
+ key TEXT NOT NULL,
23
+ value TEXT NOT NULL,
24
+ value_type TEXT NOT NULL DEFAULT 'json'
25
+ CHECK (value_type IN ('json','string','integer')),
26
+ expires_at INTEGER,
27
+ created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
28
+ updated_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
29
+ PRIMARY KEY (namespace, key)
30
+ ) WITHOUT ROWID;
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_kv_expires
33
+ ON kv_entries(expires_at)
34
+ WHERE expires_at IS NOT NULL;
@@ -0,0 +1,9 @@
1
+ -- Adds a per-page view counter to the `pages` table. Bumped on every
2
+ -- successful 200 from `GET /p/:id` (HTML inline serve) and `GET /p/:id.json`
3
+ -- (JSON metadata fetch). 302/401/403/404 responses do NOT bump. No
4
+ -- per-viewer dedup — Taras explicitly wanted a "super simple counter field".
5
+ --
6
+ -- Bump path: src/http/pages-public.ts → bumpViewCount() → incrementPageViewCount()
7
+ -- in src/be/db.ts. Wrapped in try/catch so analytics never breaks page serving.
8
+
9
+ ALTER TABLE pages ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0;
@@ -2,7 +2,7 @@
2
2
  * Provider-agnostic credential check dispatcher (WORKER-ONLY).
3
3
  *
4
4
  * Lives in `src/commands/` because the predicates value-import worker-harness
5
- * SDKs (e.g. `@mariozechner/pi-coding-agent` via `pi-mono-adapter.ts`) that
5
+ * SDKs (e.g. `@earendil-works/pi-coding-agent` via `pi-mono-adapter.ts`) that
6
6
  * have module-load side effects. Importing this file from any module
7
7
  * reachable from `src/http.ts` would drag those SDKs into the bun-compiled
8
8
  * API binary — which is exactly the bug PR #452 hit at `/usr/local/bin/`.
package/src/http/index.ts CHANGED
@@ -31,6 +31,7 @@ import { handleEvents } from "./events";
31
31
  import { handleHeartbeat } from "./heartbeat";
32
32
  import { handleInboxState } from "./inbox-state";
33
33
  import { handleIntegrations } from "./integrations";
34
+ import { handleKv } from "./kv";
34
35
  import { handleMcp } from "./mcp";
35
36
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
36
37
  import { handleMcpServers } from "./mcp-servers";
@@ -142,6 +143,7 @@ const httpServer = createHttpServer(async (req, res) => {
142
143
  () => handleWorkflowEvents(req, res, pathSegments, queryParams),
143
144
  () => handleApprovalRequests(req, res, pathSegments, queryParams),
144
145
  () => handleConfig(req, res, pathSegments, queryParams),
146
+ () => handleKv(req, res, pathSegments, queryParams),
145
147
  () => handleIntegrations(req, res, pathSegments),
146
148
  () => handlePromptTemplates(req, res, pathSegments, queryParams),
147
149
  () => handleDbQuery(req, res, pathSegments, queryParams),