@securecoms/agent-runtime 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -286,7 +286,7 @@ to a host the operator did not name.
286
286
  | `fetch_url` | `url` | Fetches a page and returns extracted text (HTML is stripped to text). Up to 3 redirects, 10 s timeout, 2 MB response cap. |
287
287
  | `web_search` | `query`, `count` (1–10, default 5) | `requires: [search_provider]` — needs a readable, non-empty `SECURECOMS_BRAVE_KEY_FILE` or `SECURECOMS_TAVILY_KEY_FILE`; Brave is used first when both are set. |
288
288
  | `http_request` | `name` (a configured `endpoint:<name>`), `path` (a query string is kept, a `#fragment` is dropped), `method` (GET/POST/PUT/DELETE), `body` | `requires: [any_endpoint]`. The model names an endpoint and a path, never a host; a path that would leave the endpoint's `baseUrl` origin is refused. |
289
- | `sql_query` | `name` (a configured `database:<name>`), `sql` (read-only `SELECT` only; a `;` or a write keyword anywhere in the text — inside a string literal or a comment included — is refused) | `requires: [any_database]`. SQLite or Postgres (`pg` required separately for Postgres). Results capped at 200 rows. |
289
+ | `sql_query` | `name` (a configured `database:<name>`), `sql` (read-only `SELECT` only; a `;` or a write keyword anywhere in the text — inside a string literal or a comment included — is refused) | `requires: [any_database]`. SQLite or Postgres (`pg` required separately for Postgres). Results capped at 200 rows. Postgres values come back as the server's text (json/jsonb as a JSON string, arrays like `{1,2}`, timestamps, int8 and numeric as strings); rows past a 1 MiB value budget are dropped, and `truncated_reason` (`row_limit` or `size_limit`) says why rows are missing. |
290
290
 
291
291
  ## skills.json
292
292
 
package/dist/bin.js CHANGED
@@ -2449,6 +2449,7 @@ var httpRequest = async (args, ctx) => {
2449
2449
  // src/builtins/sql-query.ts
2450
2450
  import { readFileSync as readFileSync5 } from "node:fs";
2451
2451
  import { spawn } from "node:child_process";
2452
+ import { createConnection } from "node:net";
2452
2453
 
2453
2454
  // src/sql-guard.ts
2454
2455
  var SqlNotReadOnlyError = class extends Error {
@@ -2641,15 +2642,181 @@ function executeSqliteQuery(path, sql, options = {}) {
2641
2642
  }));
2642
2643
  });
2643
2644
  }
2644
- function envelope(columns, rows, truncated) {
2645
+ var PG_WIRE_BYTE_CAP = 4 * 1024 * 1024;
2646
+ var PG_ROW_BYTE_BUDGET = SQLITE_IPC_CAP_BYTES;
2647
+ var PG_IDLE_IN_TRANSACTION_MS = 15e3;
2648
+ var PG_CURSOR = "sc_q";
2649
+ var PG_SCALAR_PARSERS = {
2650
+ 16: (t) => t === "t",
2651
+ // bool
2652
+ 21: Number,
2653
+ // int2
2654
+ 23: Number,
2655
+ // int4
2656
+ 26: Number,
2657
+ // oid
2658
+ 700: Number,
2659
+ // float4
2660
+ 701: Number
2661
+ // float8
2662
+ };
2663
+ var PG_TEXT_TYPES = {
2664
+ getTypeParser(oid, format) {
2665
+ if (format === "binary") return (value) => value;
2666
+ return PG_SCALAR_PARSERS[oid] ?? ((value) => value);
2667
+ }
2668
+ };
2669
+ var PgWireByteCapError = class extends Error {
2670
+ constructor() {
2671
+ super("database result exceeded the size limit");
2672
+ this.name = "PgWireByteCapError";
2673
+ }
2674
+ };
2675
+ var PgWireBoundUnavailableError = class extends Error {
2676
+ constructor() {
2677
+ super(
2678
+ 'the installed "pg" driver does not expose its socket, so the result size cannot be bounded; the query was not run'
2679
+ );
2680
+ this.name = "PgWireBoundUnavailableError";
2681
+ }
2682
+ };
2683
+ var PgDriverMissingError = class extends Error {
2684
+ };
2685
+ function isPgStream(value) {
2686
+ return typeof value === "object" && value !== null && typeof value.on === "function" && typeof value.destroy === "function";
2687
+ }
2688
+ async function loadInstalledPg() {
2689
+ return import("pg");
2690
+ }
2691
+ function rowValueBytes(row) {
2692
+ let n = 0;
2693
+ for (const v of row) n += typeof v === "string" ? Buffer.byteLength(v) : 8;
2694
+ return n;
2695
+ }
2696
+ function sendCancelRequest(client) {
2697
+ const { processID, secretKey, host, port } = client;
2698
+ if (typeof processID !== "number" || typeof secretKey !== "number") return;
2699
+ if (typeof host !== "string" || typeof port !== "number") return;
2700
+ const packet = Buffer.alloc(16);
2701
+ packet.writeInt32BE(16, 0);
2702
+ packet.writeInt32BE(80877102, 4);
2703
+ packet.writeInt32BE(processID, 8);
2704
+ packet.writeInt32BE(secretKey, 12);
2705
+ const socket = host.startsWith("/") ? createConnection(`${host}/.s.PGSQL.${port}`) : createConnection(port, host);
2706
+ socket.setTimeout(PG_CANCEL_TIMEOUT_MS, () => socket.destroy());
2707
+ socket.on("error", () => socket.destroy());
2708
+ socket.once("connect", () => socket.end(packet));
2709
+ socket.unref();
2710
+ }
2711
+ var PG_CANCEL_TIMEOUT_MS = 5e3;
2712
+ async function executePostgresQuery(connectionString, sql, options = {}) {
2713
+ const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? SQL_TIMEOUT_MS));
2714
+ const wireByteCap = options.wireByteCap ?? PG_WIRE_BYTE_CAP;
2715
+ if (options.signal?.aborted) throw new Error("Postgres query cancelled");
2716
+ let pg;
2717
+ try {
2718
+ pg = await (options.loadDriver ?? loadInstalledPg)();
2719
+ } catch {
2720
+ throw new PgDriverMissingError('the optional "pg" module is not installed (npm i -g pg)');
2721
+ }
2722
+ const Client = pg.Client ?? pg.default?.Client;
2723
+ if (!Client) throw new Error(`the installed "pg" module does not export a Client`);
2724
+ const client = new Client({
2725
+ connectionString,
2726
+ // never logged (D4)
2727
+ statement_timeout: timeoutMs,
2728
+ connectionTimeoutMillis: timeoutMs,
2729
+ types: PG_TEXT_TYPES
2730
+ });
2731
+ client.on("error", () => {
2732
+ });
2733
+ let stopped;
2734
+ const stop = (reason) => {
2735
+ if (stopped) return;
2736
+ stopped = reason;
2737
+ sendCancelRequest(client);
2738
+ const stream = client.connection?.stream;
2739
+ if (isPgStream(stream)) stream.destroy();
2740
+ else void client.end().catch(() => {
2741
+ });
2742
+ };
2743
+ const onAbort = () => stop("cancelled");
2744
+ const deadline = setTimeout(() => stop("deadline"), timeoutMs);
2745
+ options.signal?.addEventListener("abort", onAbort, { once: true });
2746
+ const stoppedError = () => {
2747
+ if (stopped === "capped") return new PgWireByteCapError();
2748
+ if (stopped === "deadline") return new Error(`Postgres query timed out after ${timeoutMs}ms`);
2749
+ if (stopped === "cancelled") return new Error("Postgres query cancelled");
2750
+ return void 0;
2751
+ };
2752
+ let began = false;
2753
+ try {
2754
+ try {
2755
+ await client.connect();
2756
+ } catch (error) {
2757
+ throw stoppedError() ?? error;
2758
+ }
2759
+ const early = stoppedError();
2760
+ if (early) throw early;
2761
+ const stream = client.connection?.stream;
2762
+ if (!isPgStream(stream)) throw new PgWireBoundUnavailableError();
2763
+ let wireBytes = 0;
2764
+ stream.on("data", (chunk) => {
2765
+ if (stopped) return;
2766
+ wireBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
2767
+ if (wireBytes > wireByteCap) stop("capped");
2768
+ });
2769
+ const run = async (text, rowMode) => {
2770
+ try {
2771
+ const result = await client.query(rowMode ? { text, rowMode } : text);
2772
+ const late = stoppedError();
2773
+ if (late) throw late;
2774
+ return result;
2775
+ } catch (error) {
2776
+ throw stoppedError() ?? error;
2777
+ }
2778
+ };
2779
+ began = true;
2780
+ await run("BEGIN READ ONLY");
2781
+ await run(`SET LOCAL statement_timeout = ${timeoutMs}`);
2782
+ await run(`SET LOCAL idle_in_transaction_session_timeout = ${Math.max(timeoutMs, PG_IDLE_IN_TRANSACTION_MS)}`);
2783
+ await run(`DECLARE ${PG_CURSOR} NO SCROLL CURSOR FOR ${sql}`);
2784
+ const fetched = await run(`FETCH FORWARD ${SQL_ROW_CAP + 1} FROM ${PG_CURSOR}`, "array");
2785
+ const columns = fetched.fields.map((f) => f.name);
2786
+ const all = fetched.rows;
2787
+ let truncatedReason = all.length > SQL_ROW_CAP ? "row_limit" : void 0;
2788
+ const rows = [];
2789
+ let bytes = 0;
2790
+ for (const row of all.slice(0, SQL_ROW_CAP)) {
2791
+ bytes += rowValueBytes(row);
2792
+ if (bytes > PG_ROW_BYTE_BUDGET) {
2793
+ truncatedReason = "size_limit";
2794
+ break;
2795
+ }
2796
+ rows.push(row);
2797
+ }
2798
+ return { columns, rows, truncated: truncatedReason !== void 0, truncatedReason };
2799
+ } finally {
2800
+ options.signal?.removeEventListener("abort", onAbort);
2801
+ if (began && !stopped) await client.query("ROLLBACK").catch(() => {
2802
+ });
2803
+ if (stopped) void client.end().catch(() => {
2804
+ });
2805
+ else await client.end().catch(() => {
2806
+ });
2807
+ clearTimeout(deadline);
2808
+ }
2809
+ }
2810
+ function envelope(columns, rows, truncated, reason) {
2645
2811
  let kept = rows;
2646
- let cut = truncated;
2812
+ let why = truncated ? reason ?? "row_limit" : void 0;
2813
+ const render = () => JSON.stringify(why ? { columns, rows: kept, truncated: true, truncated_reason: why } : { columns, rows: kept, truncated: false });
2647
2814
  try {
2648
- let out = JSON.stringify({ columns, rows: kept, truncated: cut });
2815
+ let out = render();
2649
2816
  while (resultBytes(out) > RESULT_CAP && kept.length > 0) {
2650
2817
  kept = kept.slice(0, Math.floor(kept.length / 2));
2651
- cut = true;
2652
- out = JSON.stringify({ columns, rows: kept, truncated: cut });
2818
+ why = "size_limit";
2819
+ out = render();
2653
2820
  }
2654
2821
  if (resultBytes(out) <= RESULT_CAP) return out;
2655
2822
  } catch {
@@ -2674,46 +2841,21 @@ var sqlQuery = async (args, ctx) => {
2674
2841
  try {
2675
2842
  if ("sqlite" in db) {
2676
2843
  const result = await executeSqliteQuery(db.sqlite, sql, { signal: ctx.signal });
2677
- return envelope(result.columns, result.rows, result.truncated);
2844
+ return envelope(result.columns, result.rows, result.truncated, result.truncatedReason);
2678
2845
  }
2679
- let pg;
2680
2846
  try {
2681
- pg = await import("pg");
2682
- } catch {
2683
- return toolError(`database "${name}" is Postgres, but the optional "pg" module is not installed (npm i -g pg)`);
2684
- }
2685
- const Client = pg.Client ?? pg.default?.Client;
2686
- if (!Client) return toolError(`the installed "pg" module does not export a Client`);
2687
- const client = new Client({
2688
- connectionString: readFileSync5(db.postgresUrlFile, "utf8").trim(),
2689
- // never logged (D4)
2690
- statement_timeout: SQL_TIMEOUT_MS,
2691
- connectionTimeoutMillis: SQL_TIMEOUT_MS,
2692
- query_timeout: SQL_TIMEOUT_MS
2693
- });
2694
- let cancelled = ctx.signal?.aborted ?? false;
2695
- const cancel = () => {
2696
- cancelled = true;
2697
- void client.end().catch(() => {
2698
- });
2699
- };
2700
- if (cancelled) throw new Error("Postgres query cancelled");
2701
- ctx.signal?.addEventListener("abort", cancel, { once: true });
2702
- try {
2703
- await client.connect();
2704
- if (cancelled) throw new Error("Postgres query cancelled");
2705
- await client.query("BEGIN; SET TRANSACTION READ ONLY;");
2706
- const res = await client.query({ text: sql, rowMode: "array" });
2707
- if (cancelled) throw new Error("Postgres query cancelled");
2708
- const columns = res.fields.map((f) => f.name);
2709
- const rows = res.rows.slice(0, SQL_ROW_CAP);
2710
- return envelope(columns, rows, res.rows.length > SQL_ROW_CAP);
2711
- } finally {
2712
- ctx.signal?.removeEventListener("abort", cancel);
2713
- if (!cancelled) await client.query("ROLLBACK").catch(() => {
2714
- });
2715
- await client.end().catch(() => {
2716
- });
2847
+ const result = await executePostgresQuery(
2848
+ readFileSync5(db.postgresUrlFile, "utf8").trim(),
2849
+ // never logged (D4)
2850
+ sql,
2851
+ { signal: ctx.signal }
2852
+ );
2853
+ return envelope(result.columns, result.rows, result.truncated, result.truncatedReason);
2854
+ } catch (e) {
2855
+ if (e instanceof PgDriverMissingError) {
2856
+ return toolError(`database "${name}" is Postgres, but ${e.message}`);
2857
+ }
2858
+ throw e;
2717
2859
  }
2718
2860
  } catch (e) {
2719
2861
  return toolError(e instanceof Error ? e.message : String(e));
@@ -3340,6 +3482,49 @@ import { readFileSync as readFileSync7 } from "node:fs";
3340
3482
  import { WebSocket } from "node:http";
3341
3483
  import { homedir as homedir3 } from "node:os";
3342
3484
  import { join as join5 } from "node:path";
3485
+
3486
+ // src/status-reporter.ts
3487
+ function createStatusReporter(log, fetchImpl = fetch) {
3488
+ const channels = /* @__PURE__ */ new Map();
3489
+ async function post(endpoint, channelId, state) {
3490
+ try {
3491
+ const res = await fetchImpl(`${endpoint.httpBase}/v1/status`, {
3492
+ method: "POST",
3493
+ headers: { "content-type": "application/json", authorization: `Bearer ${endpoint.token}` },
3494
+ body: JSON.stringify({ channel_id: channelId, state })
3495
+ });
3496
+ if (!res.ok) log("warn", "transport.status_failed", { state: logLabel(state), http_status: res.status });
3497
+ await res.body?.cancel();
3498
+ } catch (e) {
3499
+ log("warn", "transport.status_failed", { state: logLabel(state) }, e);
3500
+ }
3501
+ }
3502
+ async function drain(channelId, entry) {
3503
+ while (entry.desired !== entry.sent) {
3504
+ const state = entry.desired;
3505
+ entry.sent = state;
3506
+ await post(entry.endpoint, channelId, state);
3507
+ }
3508
+ entry.inFlight = false;
3509
+ if (channels.get(channelId) === entry) channels.delete(channelId);
3510
+ }
3511
+ return {
3512
+ set(endpoint, channelId, state) {
3513
+ let entry = channels.get(channelId);
3514
+ if (!entry) {
3515
+ entry = { desired: state, endpoint, sent: null, inFlight: false };
3516
+ channels.set(channelId, entry);
3517
+ }
3518
+ entry.desired = state;
3519
+ entry.endpoint = endpoint;
3520
+ if (entry.inFlight) return;
3521
+ entry.inFlight = true;
3522
+ void drain(channelId, entry);
3523
+ }
3524
+ };
3525
+ }
3526
+
3527
+ // src/data-plane.ts
3343
3528
  var NO_TURN_MEMORY = Object.freeze({
3344
3529
  recent: Object.freeze([]),
3345
3530
  facts: Object.freeze([])
@@ -3408,6 +3593,12 @@ function parseTurnAuthorization(value) {
3408
3593
  tool_grants: Object.freeze(grants)
3409
3594
  });
3410
3595
  }
3596
+ var VOICE_GUIDANCE_MAX_CHARS = 1e3;
3597
+ function parseVoiceGuidance(frame) {
3598
+ if (frame.voice !== true || typeof frame.guidance !== "string") return void 0;
3599
+ const g = frame.guidance.trim().slice(0, VOICE_GUIDANCE_MAX_CHARS);
3600
+ return g || void 0;
3601
+ }
3411
3602
  var SEEN_MAX = 5e3;
3412
3603
  var PENDING_REPLY_MAX = 1e3;
3413
3604
  var TERMINAL_REPLY_ERRORS = /* @__PURE__ */ new Set(["agent_paused", "send_forbidden"]);
@@ -3473,6 +3664,8 @@ var SecureComsDataPlane = class {
3473
3664
  seq = 0;
3474
3665
  /** in_reply_to → timing of a reply handed to the daemon, until its `sent` frame. */
3475
3666
  pendingReplies = /* @__PURE__ */ new Map();
3667
+ /** #910: working/idle for the web "thinking" strip; fire-and-forget, per-channel ordered. */
3668
+ status;
3476
3669
  constructor(cfg, onMessage) {
3477
3670
  this.identityId = cfg.identityId;
3478
3671
  const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
@@ -3492,6 +3685,7 @@ var SecureComsDataPlane = class {
3492
3685
  this.onMessage = onMessage;
3493
3686
  this.noticeReplies = cfg.noticeReplies ?? /* @__PURE__ */ new Set();
3494
3687
  this.clock = cfg.clock ?? (() => performance.now());
3688
+ this.status = createStatusReporter(this.log);
3495
3689
  }
3496
3690
  elapsed(from, to = this.clock()) {
3497
3691
  return Math.max(0, Math.round(to - from));
@@ -3759,6 +3953,7 @@ var SecureComsDataPlane = class {
3759
3953
  let handlerMs = 0;
3760
3954
  let sendMs = 0;
3761
3955
  let outcome = "failed";
3956
+ let reportedWorking = false;
3762
3957
  try {
3763
3958
  if (!this.seen.has(messageId)) {
3764
3959
  const receiptStart = this.clock();
@@ -3785,26 +3980,33 @@ var SecureComsDataPlane = class {
3785
3980
  this.turns.set(ws, socketTurns);
3786
3981
  }
3787
3982
  socketTurns.add(turnController);
3983
+ const text = String(frame.text ?? "");
3984
+ if (text.trim()) {
3985
+ reportedWorking = true;
3986
+ this.status.set({ httpBase, token }, channelId, "working");
3987
+ }
3788
3988
  const handlerStart = this.clock();
3989
+ const voiceGuidance = parseVoiceGuidance(frame);
3789
3990
  const reply = await this.onMessage({
3790
- text: String(frame.text ?? ""),
3991
+ text,
3791
3992
  senderId: String(frame.sender_id ?? ""),
3792
3993
  channelId,
3793
3994
  messageId,
3794
3995
  authorization: parseTurnAuthorization(frame.authorization),
3795
3996
  memory: parseTurnMemory(frame.memory),
3796
- seq: arrival.seq
3997
+ seq: arrival.seq,
3998
+ ...voiceGuidance ? { voiceGuidance } : {}
3797
3999
  }, turnController.signal);
3798
4000
  handlerMs = this.elapsed(handlerStart);
3799
4001
  if (reply != null) {
3800
- const text = String(reply);
4002
+ const text2 = String(reply);
3801
4003
  const sendStart = this.clock();
3802
4004
  ws.send(JSON.stringify({
3803
4005
  type: "send",
3804
4006
  channel_id: channelId,
3805
- text,
4007
+ text: text2,
3806
4008
  in_reply_to: messageId,
3807
- ...this.noticeReplies.has(text) ? { notice: true } : {}
4009
+ ...this.noticeReplies.has(text2) ? { notice: true } : {}
3808
4010
  }));
3809
4011
  const sentAt = this.clock();
3810
4012
  sendMs = this.elapsed(sendStart, sentAt);
@@ -3823,6 +4025,7 @@ var SecureComsDataPlane = class {
3823
4025
  this.log("error", "transport.handler_failed", {}, e);
3824
4026
  this.failTurn(ws, messageId, "turn_failed");
3825
4027
  } finally {
4028
+ if (reportedWorking) this.status.set({ httpBase, token }, channelId, "idle");
3826
4029
  if (turnController) {
3827
4030
  const socketTurns = this.turns.get(ws);
3828
4031
  socketTurns?.delete(turnController);
@@ -3892,6 +4095,7 @@ async function runToolCalls(toolCalls, skills, context) {
3892
4095
  }
3893
4096
 
3894
4097
  // src/turn-loop.ts
4098
+ var VOICE_TURN_PREFIX = "This message was spoken on a live voice call and your reply will be read aloud: plain spoken sentences, no markdown, lists, links or code. Use your tools as usual when the question needs them. ";
3895
4099
  var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent working inside a private, end-to-end encrypted messaging enclave for one business; the people you talk to are its staff, partners and customers. Answer questions in the chat, and keep replies to the length the question needs.";
3896
4100
  var DEFAULT_MAX_ITERATIONS = 5;
3897
4101
  var DEFAULT_MAX_CONCURRENT_TURNS = 4;
@@ -4313,7 +4517,12 @@ function buildHandler(deps) {
4313
4517
  const memory = memoryFor(m.channelId);
4314
4518
  memory.replace(m.memory);
4315
4519
  const promptStart = clock();
4316
- const system = { role: "system", content: deps.systemPromptFor() };
4520
+ const system = {
4521
+ role: "system",
4522
+ content: m.voiceGuidance ? `${deps.systemPromptFor()}
4523
+
4524
+ ${VOICE_TURN_PREFIX}${m.voiceGuidance}` : deps.systemPromptFor()
4525
+ };
4317
4526
  const recallStart = clock();
4318
4527
  const recalledText = await memory.recall(text);
4319
4528
  live();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-runtime",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "securecoms-agent": "dist/bin.js"
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {},
34
34
  "peerDependencies": {
35
- "pg": ">=8"
35
+ "pg": ">=8.11 <9"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "pg": {
@@ -42,6 +42,7 @@
42
42
  "devDependencies": {
43
43
  "@types/ws": "^8.5.12",
44
44
  "esbuild": "0.24.0",
45
+ "pg": "~8.23.0",
45
46
  "typescript": "^5.4.0",
46
47
  "vitest": "^3.2.6",
47
48
  "ws": "^8.21.0"
@@ -18,4 +18,10 @@ database the operator configured; never invent a connection string. A semicolon
18
18
  or a write keyword inside a string literal or a comment is also refused, so
19
19
  rewrite such a value out of the query (a bound-looking literal like '--' or
20
20
  'DROP' will not pass). If a query is refused, rewrite it as a SELECT rather than
21
- retrying the same statement.
21
+ retrying the same statement. Values come back as the database's text: a
22
+ Postgres json or jsonb value is a JSON string, arrays look like {1,2}, and
23
+ timestamps, int8 and numeric are strings. When `truncated` is true,
24
+ `truncated_reason` says why: `row_limit` means more rows exist than were
25
+ returned, and `size_limit` means rows were dropped because they were too large
26
+ (a single row over 1 MiB comes back as no rows with `size_limit`, not as an
27
+ empty result). Select fewer or narrower columns rather than retrying as is.