@signalvaultio/node 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  AI audit logs and guardrails for your OpenAI and Anthropic applications.
4
4
 
5
+ > **Server-side only.** This SDK uses Node APIs (`async_hooks`, `fetch`) and
6
+ > requires Node.js **18+**. Do **not** bundle it for the browser — your
7
+ > `apiKey`, `openaiApiKey`, and `anthropicApiKey` would be exposed to end
8
+ > users. Always call SignalVault from your backend.
9
+
5
10
  ## Installation
6
11
 
7
12
  ```bash
@@ -86,6 +91,69 @@ for await (const event of stream) {
86
91
  }
87
92
  ```
88
93
 
94
+ ## Agent Tool-Use Capture
95
+
96
+ Log every tool invocation made by your agents — name, input, output, duration, and any error — as auditable events alongside your LLM calls.
97
+
98
+ ### Wrapper API (recommended)
99
+
100
+ ```typescript
101
+ const fetchWeather = client.tool('fetch_weather', async (city: string) => {
102
+ const res = await fetch(`https://api.example.com/weather?city=${city}`);
103
+ return res.json();
104
+ });
105
+
106
+ // Call it like the original — SignalVault auto-times and audits
107
+ const weather = await fetchWeather('London');
108
+ ```
109
+
110
+ The wrapper records the call asynchronously (no impact on your tool's latency) and passes the result through unchanged. Errors are recorded and rethrown.
111
+
112
+ ### Manual API
113
+
114
+ ```typescript
115
+ await client.tools.record({
116
+ toolName: 'fetch_weather',
117
+ toolInput: { city: 'London' },
118
+ toolOutput: { temp: 12.3 },
119
+ durationMs: 142,
120
+ });
121
+ ```
122
+
123
+ ### What gets captured (and what to keep out)
124
+
125
+ When you wrap a tool or call `tools.record()`, SignalVault captures:
126
+
127
+ - `tool_name` (truncated to 200 bytes)
128
+ - `tool_input` — the function arguments, JSON-serialized (capped at 256 KB; oversize values are truncated with a marker)
129
+ - `tool_output` — the function return value, JSON-serialized (same 256 KB cap)
130
+ - `error.message` if the tool throws (truncated to 1900 bytes)
131
+ - `duration_ms`, `started_at`, and any `metadata` you attach
132
+
133
+ These fields are stored encrypted at rest server-side, but they go on the wire
134
+ to SignalVault's API. **If you pass user PII, secrets, or API keys as tool
135
+ arguments, those values will leave your process and be stored in SignalVault.**
136
+ Recommendations:
137
+
138
+ - Sanitize sensitive arguments before invoking the wrapped tool, or use the
139
+ manual `tools.record()` API and pass a redacted copy.
140
+ - Don't put secrets in error messages — they end up in `error` verbatim.
141
+ - Use `metadata` for non-sensitive identifiers (`user_id`, `feature`,
142
+ `workspace_id`); avoid putting raw user content in metadata.
143
+
144
+ ### Linking tool calls to a parent LLM turn
145
+
146
+ Wrap your agent loop in `withContext` and tool calls inside auto-correlate to the given `requestId`:
147
+
148
+ ```typescript
149
+ await client.withContext({ requestId: 'agent-turn-abc' }, async () => {
150
+ const llmResponse = await client.chat.completions.create({...});
151
+ await fetchWeather('London'); // auto-linked to 'agent-turn-abc'
152
+ });
153
+ ```
154
+
155
+ Without `withContext`, tool calls are recorded as orphans (no parent request).
156
+
89
157
  ## Metadata
90
158
 
91
159
  Attach contextual metadata to every event — perfect for audit trails, user attribution, and analytics:
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("../index");
4
+ describe('Agent tool-use capture', () => {
5
+ let fetchMock;
6
+ let client;
7
+ beforeEach(() => {
8
+ fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({ event_id: 'evt_123' }), { status: 200 }));
9
+ client = new index_1.SignalVaultClient({
10
+ apiKey: 'sk_test_abc',
11
+ openaiApiKey: 'sk-fake',
12
+ baseUrl: 'https://api.example.com',
13
+ });
14
+ });
15
+ afterEach(() => {
16
+ fetchMock.mockRestore();
17
+ });
18
+ // Helper: lets the fire-and-forget tool record promise settle.
19
+ const flushAsync = () => new Promise((resolve) => setImmediate(resolve));
20
+ describe('client.tool() wrapper', () => {
21
+ it('records a successful tool invocation with input/output and timing', async () => {
22
+ const fetchWeather = client.tool('fetch_weather', async (city) => {
23
+ return { temp: 12.3, conditions: 'rain', for: city };
24
+ });
25
+ const result = await fetchWeather('London');
26
+ expect(result).toEqual({ temp: 12.3, conditions: 'rain', for: 'London' });
27
+ await flushAsync();
28
+ expect(fetchMock).toHaveBeenCalledTimes(1);
29
+ const [url, init] = fetchMock.mock.calls[0];
30
+ expect(url).toBe('https://api.example.com/v1/events');
31
+ const body = JSON.parse(init.body);
32
+ expect(body.type).toBe('agent.tool_call');
33
+ expect(body.payload.tool_name).toBe('fetch_weather');
34
+ expect(body.payload.tool_input).toBe('London');
35
+ expect(body.payload.tool_output).toEqual({ temp: 12.3, conditions: 'rain', for: 'London' });
36
+ expect(body.payload.duration_ms).toBeGreaterThanOrEqual(0);
37
+ expect(body.payload.started_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
38
+ });
39
+ it('records an errored invocation and rethrows', async () => {
40
+ const failingTool = client.tool('fetch_external', async () => {
41
+ throw new Error('connection refused');
42
+ });
43
+ await expect(failingTool()).rejects.toThrow('connection refused');
44
+ await flushAsync();
45
+ expect(fetchMock).toHaveBeenCalledTimes(1);
46
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
47
+ expect(body.payload.error).toBe('connection refused');
48
+ expect(body.payload.tool_output).toBeNull();
49
+ });
50
+ it('serializes a single object arg as the value, not as an array', async () => {
51
+ const search = client.tool('search', async (query) => ({ matches: query.k }));
52
+ await search({ q: 'embeddings', k: 5 });
53
+ await flushAsync();
54
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
55
+ expect(body.payload.tool_input).toEqual({ q: 'embeddings', k: 5 });
56
+ });
57
+ it('serializes multiple args as an array', async () => {
58
+ const add = client.tool('add', async (a, b) => a + b);
59
+ await add(2, 3);
60
+ await flushAsync();
61
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
62
+ expect(body.payload.tool_input).toEqual([2, 3]);
63
+ });
64
+ it('does not block on the audit network call (fire-and-forget)', async () => {
65
+ // Make fetch hang so we can verify the wrapper returns before the audit completes
66
+ fetchMock.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(new Response('{}', { status: 200 })), 5000)));
67
+ const start = Date.now();
68
+ const tool = client.tool('fast_tool', async () => 42);
69
+ const result = await tool();
70
+ const elapsed = Date.now() - start;
71
+ expect(result).toBe(42);
72
+ expect(elapsed).toBeLessThan(100); // should NOT wait the 5s audit timeout
73
+ });
74
+ it('supports synchronous functions', async () => {
75
+ const sum = client.tool('sum', (a, b) => a + b);
76
+ const result = await sum(2, 3);
77
+ expect(result).toBe(5);
78
+ });
79
+ });
80
+ describe('client.tools.record() manual API', () => {
81
+ it('posts a tool_call event with the given fields', async () => {
82
+ await client.tools.record({
83
+ toolName: 'manual_tool',
84
+ toolInput: { x: 1 },
85
+ toolOutput: { y: 2 },
86
+ durationMs: 50,
87
+ requestId: 'req-explicit',
88
+ });
89
+ expect(fetchMock).toHaveBeenCalledTimes(1);
90
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
91
+ expect(body.type).toBe('agent.tool_call');
92
+ expect(body.request_id).toBe('req-explicit');
93
+ expect(body.payload.tool_name).toBe('manual_tool');
94
+ expect(body.payload.duration_ms).toBe(50);
95
+ });
96
+ it('omits request_id when not provided (orphan tool call)', async () => {
97
+ await client.tools.record({
98
+ toolName: 'orphan',
99
+ durationMs: 10,
100
+ });
101
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
102
+ expect(body.request_id).toBeUndefined();
103
+ });
104
+ });
105
+ describe('input sanitization', () => {
106
+ it('replaces circular references with a marker instead of throwing', async () => {
107
+ const circular = { name: 'root' };
108
+ circular.self = circular;
109
+ const tool = client.tool('circular_tool', async (_arg) => 'ok');
110
+ await tool(circular);
111
+ await flushAsync();
112
+ expect(fetchMock).toHaveBeenCalledTimes(1);
113
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
114
+ expect(body.payload.tool_input).toEqual({ name: 'root', self: '[Circular]' });
115
+ });
116
+ it('serializes BigInt arguments as a string with n suffix', async () => {
117
+ const tool = client.tool('bigint_tool', async (_n) => 'ok');
118
+ await tool(BigInt(42));
119
+ await flushAsync();
120
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
121
+ expect(body.payload.tool_input).toBe('42n');
122
+ });
123
+ it('truncates oversized tool_output with a marker', async () => {
124
+ // ~300 KB — over the 256 KB cap.
125
+ const big = 'x'.repeat(300 * 1024);
126
+ const tool = client.tool('big_output', async () => ({ blob: big }));
127
+ await tool();
128
+ await flushAsync();
129
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
130
+ expect(body.payload.tool_output._signalvault_truncated).toBe(true);
131
+ expect(body.payload.tool_output._signalvault_original_bytes).toBeGreaterThan(256 * 1024);
132
+ expect(typeof body.payload.tool_output.preview).toBe('string');
133
+ });
134
+ it('truncates a long error message to ~1900 bytes', async () => {
135
+ const longMsg = 'a'.repeat(5000);
136
+ const failing = client.tool('long_error', async () => {
137
+ throw new Error(longMsg);
138
+ });
139
+ await expect(failing()).rejects.toThrow();
140
+ await flushAsync();
141
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
142
+ const err = body.payload.error;
143
+ // Allow a generous upper bound to account for the truncation marker.
144
+ expect(Buffer.byteLength(err, 'utf8')).toBeLessThanOrEqual(1900);
145
+ expect(err).toMatch(/truncated/);
146
+ });
147
+ it('truncates an oversized tool_name and still records the call', async () => {
148
+ const longName = 'n'.repeat(500);
149
+ await client.tools.record({
150
+ toolName: longName,
151
+ durationMs: 1,
152
+ });
153
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
154
+ expect(Buffer.byteLength(body.payload.tool_name, 'utf8')).toBeLessThanOrEqual(200);
155
+ expect(body.payload.tool_name).toMatch(/truncated/);
156
+ });
157
+ it('rejects an empty tool name from the manual API', async () => {
158
+ await expect(client.tools.record({ toolName: '', durationMs: 1 })).rejects.toThrow(/non-empty string/);
159
+ expect(fetchMock).not.toHaveBeenCalled();
160
+ });
161
+ it('replaces undefined tool_output with null in the body', async () => {
162
+ const tool = client.tool('void_tool', async () => undefined);
163
+ await tool();
164
+ await flushAsync();
165
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
166
+ expect(body.payload.tool_output).toBeNull();
167
+ });
168
+ it('replaces a function value with a [Function] marker (not a crash)', async () => {
169
+ const tool = client.tool('weird_input', async (_fn) => 'ok');
170
+ await tool(() => 'hello');
171
+ await flushAsync();
172
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
173
+ expect(body.payload.tool_input).toBe('[Function]');
174
+ });
175
+ it('falls back to the serialization-error envelope for top-level undefined', async () => {
176
+ // A bare `undefined` is JSON-serializable as nothing — the replacer leaves
177
+ // it alone and JSON.stringify returns undefined. We expose this as a
178
+ // marker so the audit isn't silently dropped.
179
+ await client.tools.record({
180
+ toolName: 'undef',
181
+ toolInput: undefined,
182
+ durationMs: 1,
183
+ });
184
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
185
+ // sanitizePayload(undefined) returns null per the function contract.
186
+ expect(body.payload.tool_input).toBeNull();
187
+ });
188
+ });
189
+ describe('error logging', () => {
190
+ let warnSpy;
191
+ let errorSpy;
192
+ beforeEach(() => {
193
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
194
+ errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
195
+ });
196
+ afterEach(() => {
197
+ warnSpy.mockRestore();
198
+ errorSpy.mockRestore();
199
+ });
200
+ it('surfaces 4xx responses to console.warn even with debug=false', async () => {
201
+ fetchMock.mockResolvedValue(new Response('bad request', { status: 422 }));
202
+ await client.tools.record({ toolName: 'will_fail', durationMs: 1 });
203
+ expect(warnSpy).toHaveBeenCalledTimes(1);
204
+ expect(warnSpy.mock.calls[0][0]).toMatch(/422/);
205
+ expect(warnSpy.mock.calls[0][0]).toMatch(/Check your apiKey/);
206
+ });
207
+ it('does NOT log 5xx responses unless debug is true', async () => {
208
+ fetchMock.mockResolvedValue(new Response('boom', { status: 503 }));
209
+ await client.tools.record({ toolName: 'svc_down', durationMs: 1 });
210
+ expect(warnSpy).not.toHaveBeenCalled();
211
+ expect(errorSpy).not.toHaveBeenCalled();
212
+ });
213
+ });
214
+ describe('client.withContext() correlation', () => {
215
+ it('auto-links wrapped tool calls to the surrounding requestId', async () => {
216
+ const tool = client.tool('search', async (q) => ({ q }));
217
+ await client.withContext({ requestId: 'turn-abc' }, async () => {
218
+ await tool('embeddings');
219
+ });
220
+ await flushAsync();
221
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
222
+ expect(body.request_id).toBe('turn-abc');
223
+ });
224
+ it('explicit requestId on record() overrides context', async () => {
225
+ await client.withContext({ requestId: 'context-id' }, async () => {
226
+ await client.tools.record({
227
+ toolName: 'override',
228
+ durationMs: 1,
229
+ requestId: 'explicit-id',
230
+ });
231
+ });
232
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
233
+ expect(body.request_id).toBe('explicit-id');
234
+ });
235
+ it('tools called outside any context are orphans', async () => {
236
+ const tool = client.tool('orphan_tool', async () => 'ok');
237
+ await tool();
238
+ await flushAsync();
239
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
240
+ expect(body.request_id).toBeUndefined();
241
+ });
242
+ });
243
+ });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import OpenAI from 'openai';
2
+ import { ToolsAPI } from './tools';
3
+ export type { ToolRecordOptions, ToolsAPI } from './tools';
2
4
  export interface SignalVaultConfig {
3
5
  apiKey: string;
4
6
  /** Required for OpenAI (chat.completions). */
@@ -81,6 +83,60 @@ export declare class SignalVaultClient {
81
83
  private readonly openai;
82
84
  private readonly anthropic;
83
85
  constructor(config: SignalVaultConfig);
86
+ /**
87
+ * Wraps a function so every call records an `agent.tool_call` event with
88
+ * timing, input, output, and any error. The wrapper auto-times, runs the
89
+ * inner function, fires the audit event in the background, and returns the
90
+ * inner result (or rethrows the inner error).
91
+ *
92
+ * Tool calls invoked inside a `withContext({ requestId })` block auto-pick
93
+ * up the requestId for correlation with the parent ai.request.
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * const fetchWeather = signalvault.tool('fetch_weather', async (city: string) => {
98
+ * const res = await fetch(`https://api.example.com/weather?city=${city}`);
99
+ * return res.json();
100
+ * });
101
+ *
102
+ * const weather = await fetchWeather('London');
103
+ * ```
104
+ */
105
+ tool<TArgs extends readonly unknown[], TReturn>(name: string, fn: (...args: TArgs) => TReturn | Promise<TReturn>, options?: {
106
+ metadata?: Record<string, unknown>;
107
+ }): (...args: TArgs) => Promise<TReturn>;
108
+ /**
109
+ * Manual tool recording API. Use when the wrapper isn't a fit (streaming
110
+ * tools, custom timing, post-hoc capture from logs).
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * await signalvault.tools.record({
115
+ * toolName: 'fetch_weather',
116
+ * toolInput: { city: 'London' },
117
+ * toolOutput: { temp: 12.3 },
118
+ * durationMs: 142,
119
+ * });
120
+ * ```
121
+ */
122
+ get tools(): ToolsAPI;
123
+ /**
124
+ * Runs the given async function inside a context where tool calls are
125
+ * automatically correlated to the provided `requestId`. Useful for grouping
126
+ * a multi-step agent loop under one logical turn.
127
+ *
128
+ * @example
129
+ * ```typescript
130
+ * await signalvault.withContext({ requestId: 'agent-turn-abc' }, async () => {
131
+ * const llm = await signalvault.chat.completions.create({...});
132
+ * await fetchWeather('London'); // tool call auto-linked to 'agent-turn-abc'
133
+ * });
134
+ * ```
135
+ */
136
+ withContext<T>(ctx: {
137
+ requestId?: string;
138
+ }, fn: () => Promise<T>): Promise<T>;
139
+ private sendToolCallEvent;
84
140
  get chat(): {
85
141
  completions: {
86
142
  create(params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, options?: CreateOptions): Promise<OpenAI.Chat.ChatCompletion>;
package/dist/index.js CHANGED
@@ -6,6 +6,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.SignalVaultClient = void 0;
7
7
  const openai_1 = __importDefault(require("openai"));
8
8
  const uuid_1 = require("uuid");
9
+ const tools_1 = require("./tools");
10
+ // ---------------------------------------------------------------------------
11
+ // Environment guard
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Hard-fails if the SDK is loaded in a browser context. The SDK relies on
15
+ * `async_hooks` (Node-only) and is intended for server-side use; bundling it
16
+ * for the browser would expose `apiKey`, `openaiApiKey`, and `anthropicApiKey`
17
+ * to end users — which is exactly the kind of leak this product is meant to
18
+ * prevent.
19
+ */
20
+ function assertNodeEnvironment() {
21
+ // `process` exists on Node and not in plain browsers. `window` is the
22
+ // canonical browser global. We refuse to run if we look browser-like.
23
+ const hasProcess = typeof process !== 'undefined' && process?.versions?.node;
24
+ const hasWindow = typeof globalThis.window !== 'undefined';
25
+ if (!hasProcess || hasWindow) {
26
+ throw new Error('[SignalVault] This SDK is server-side only. Do not bundle it for browser use — ' +
27
+ 'it will leak your API keys to end users. Call SignalVault from your backend instead.');
28
+ }
29
+ }
9
30
  // ---------------------------------------------------------------------------
10
31
  // SignalVaultClient
11
32
  // ---------------------------------------------------------------------------
@@ -35,6 +56,7 @@ const uuid_1 = require("uuid");
35
56
  */
36
57
  class SignalVaultClient {
37
58
  constructor(config) {
59
+ assertNodeEnvironment();
38
60
  if (!config.openaiApiKey && !config.anthropicApiKey) {
39
61
  throw new Error('[SignalVault] At least one of openaiApiKey or anthropicApiKey is required.');
40
62
  }
@@ -65,6 +87,130 @@ class SignalVaultClient {
65
87
  }
66
88
  }
67
89
  // ---------------------------------------------------------------------------
90
+ // Agent tool-use capture
91
+ // ---------------------------------------------------------------------------
92
+ /**
93
+ * Wraps a function so every call records an `agent.tool_call` event with
94
+ * timing, input, output, and any error. The wrapper auto-times, runs the
95
+ * inner function, fires the audit event in the background, and returns the
96
+ * inner result (or rethrows the inner error).
97
+ *
98
+ * Tool calls invoked inside a `withContext({ requestId })` block auto-pick
99
+ * up the requestId for correlation with the parent ai.request.
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * const fetchWeather = signalvault.tool('fetch_weather', async (city: string) => {
104
+ * const res = await fetch(`https://api.example.com/weather?city=${city}`);
105
+ * return res.json();
106
+ * });
107
+ *
108
+ * const weather = await fetchWeather('London');
109
+ * ```
110
+ */
111
+ tool(name, fn, options) {
112
+ return (0, tools_1.wrapTool)({
113
+ recordFn: (opts) => this.sendToolCallEvent(opts),
114
+ debug: this.debugMode,
115
+ }, name, fn, options);
116
+ }
117
+ /**
118
+ * Manual tool recording API. Use when the wrapper isn't a fit (streaming
119
+ * tools, custom timing, post-hoc capture from logs).
120
+ *
121
+ * @example
122
+ * ```typescript
123
+ * await signalvault.tools.record({
124
+ * toolName: 'fetch_weather',
125
+ * toolInput: { city: 'London' },
126
+ * toolOutput: { temp: 12.3 },
127
+ * durationMs: 142,
128
+ * });
129
+ * ```
130
+ */
131
+ get tools() {
132
+ return {
133
+ record: (opts) => this.sendToolCallEvent(opts),
134
+ };
135
+ }
136
+ /**
137
+ * Runs the given async function inside a context where tool calls are
138
+ * automatically correlated to the provided `requestId`. Useful for grouping
139
+ * a multi-step agent loop under one logical turn.
140
+ *
141
+ * @example
142
+ * ```typescript
143
+ * await signalvault.withContext({ requestId: 'agent-turn-abc' }, async () => {
144
+ * const llm = await signalvault.chat.completions.create({...});
145
+ * await fetchWeather('London'); // tool call auto-linked to 'agent-turn-abc'
146
+ * });
147
+ * ```
148
+ */
149
+ withContext(ctx, fn) {
150
+ return (0, tools_1.runWithContext)(ctx, fn);
151
+ }
152
+ async sendToolCallEvent(opts) {
153
+ const metadata = { ...this.defaultMetadata, ...(opts.metadata ?? {}) };
154
+ // Validate + sanitize before constructing the body. validateToolName throws
155
+ // on missing/empty name; surfacing that to the caller is intentional —
156
+ // the manual API should refuse malformed input rather than silently fail.
157
+ const safeName = (0, tools_1.validateToolName)(opts.toolName);
158
+ const payload = {
159
+ tool_name: safeName,
160
+ tool_input: (0, tools_1.sanitizePayload)(opts.toolInput),
161
+ tool_output: (0, tools_1.sanitizePayload)(opts.toolOutput),
162
+ duration_ms: opts.durationMs,
163
+ };
164
+ const safeError = (0, tools_1.sanitizeError)(opts.error);
165
+ if (safeError !== undefined)
166
+ payload.error = safeError;
167
+ if (opts.startedAt)
168
+ payload.started_at = opts.startedAt;
169
+ const body = {
170
+ type: 'agent.tool_call',
171
+ environment: this.svEnvironment,
172
+ metadata,
173
+ payload,
174
+ };
175
+ if (opts.requestId)
176
+ body.request_id = opts.requestId;
177
+ let serialized;
178
+ try {
179
+ serialized = JSON.stringify(body);
180
+ }
181
+ catch (error) {
182
+ // sanitizePayload should make this impossible, but guard the outer
183
+ // serialize too — we'd rather drop the audit than crash the app.
184
+ console.warn('[SignalVault] tool_call serialization failed, event dropped:', error);
185
+ return;
186
+ }
187
+ try {
188
+ const response = await fetch(`${this.svBaseUrl}/v1/events`, {
189
+ method: 'POST',
190
+ headers: this.authHeaders(),
191
+ signal: AbortSignal.timeout(this.bgTimeout),
192
+ body: serialized,
193
+ });
194
+ // Surface 4xx unconditionally — those are usually misconfigurations
195
+ // (bad key, malformed payload) the user must know about. 5xx are noisy
196
+ // and only logged in debug mode.
197
+ if (!response.ok) {
198
+ if (response.status >= 400 && response.status < 500) {
199
+ console.warn(`[SignalVault] tool_call rejected with ${response.status}. ` +
200
+ `Check your apiKey and event payload.`);
201
+ }
202
+ else if (this.debugMode) {
203
+ console.error('[SignalVault] tool_call event failed:', response.status);
204
+ }
205
+ }
206
+ }
207
+ catch (error) {
208
+ if (this.debugMode) {
209
+ console.error('[SignalVault] tool_call event error:', error);
210
+ }
211
+ }
212
+ }
213
+ // ---------------------------------------------------------------------------
68
214
  // OpenAI: chat.completions
69
215
  // ---------------------------------------------------------------------------
70
216
  get chat() {
@@ -0,0 +1,69 @@
1
+ interface ToolContext {
2
+ requestId?: string;
3
+ }
4
+ export interface ToolRecordOptions {
5
+ /** Logical name of the tool. Required. Truncated to 200 bytes. */
6
+ toolName: string;
7
+ /** Arguments passed to the tool. Any JSON-serializable value. */
8
+ toolInput?: unknown;
9
+ /** What the tool returned. Any JSON-serializable value. */
10
+ toolOutput?: unknown;
11
+ /** How long the tool took to run, in milliseconds. */
12
+ durationMs: number;
13
+ /** Error message if the tool threw. Truncated to 1900 bytes. */
14
+ error?: string | null;
15
+ /**
16
+ * Optional ISO8601 timestamp of when the tool started.
17
+ * Falls back to server-side `inserted_at` when omitted.
18
+ */
19
+ startedAt?: string;
20
+ /**
21
+ * Parent ai.request correlation. If omitted, the SDK uses the requestId from
22
+ * the surrounding `withContext` block (if any), else null (orphan tool call).
23
+ */
24
+ requestId?: string | null;
25
+ /** Per-call metadata, merged over client-level defaults. */
26
+ metadata?: Record<string, unknown>;
27
+ }
28
+ export interface ToolsAPI {
29
+ /**
30
+ * Records a tool call manually. Use when the wrapper isn't a fit
31
+ * (streaming tools, custom timing, post-hoc capture).
32
+ */
33
+ record(opts: ToolRecordOptions): Promise<void>;
34
+ }
35
+ /** Returns the requestId from the surrounding withContext block, or undefined. */
36
+ export declare function getCurrentRequestId(): string | undefined;
37
+ /** Runs `fn` inside an async context with the given requestId attached. */
38
+ export declare function runWithContext<T>(ctx: ToolContext, fn: () => Promise<T>): Promise<T>;
39
+ export interface ToolWrapperConfig {
40
+ recordFn: (opts: ToolRecordOptions) => Promise<void>;
41
+ debug?: boolean;
42
+ }
43
+ /**
44
+ * Wraps a function so every call records an `agent.tool_call` event with
45
+ * timing, args, result, and any error.
46
+ *
47
+ * The wrapper auto-times, JSON-serializes args/result, and captures the
48
+ * surrounding `withContext` requestId (if any). Errors propagate to the caller
49
+ * after recording — wrapping is observation, not error swallowing.
50
+ */
51
+ export declare function wrapTool<TArgs extends readonly unknown[], TReturn>(config: ToolWrapperConfig, toolName: string, fn: (...args: TArgs) => TReturn | Promise<TReturn>, options?: {
52
+ metadata?: Record<string, unknown>;
53
+ }): (...args: TArgs) => Promise<TReturn>;
54
+ /** Validates and normalizes a tool name. Throws if missing or non-string. */
55
+ export declare function validateToolName(name: unknown): string;
56
+ /** Truncates an error string to the server-accepted byte limit. */
57
+ export declare function sanitizeError(err: unknown): string | undefined;
58
+ /**
59
+ * Returns a JSON-safe version of `value`, capped at MAX_PAYLOAD_BYTES.
60
+ *
61
+ * - Circular references, BigInt, functions, Symbols → replaced with a marker
62
+ * object so the audit event is still useful instead of dropped silently.
63
+ * - Oversized values → truncated; the marker `_signalvault_truncated` is set
64
+ * so the receiver can detect partial captures.
65
+ *
66
+ * Never throws — sanitization failures degrade gracefully.
67
+ */
68
+ export declare function sanitizePayload(value: unknown): unknown;
69
+ export {};
package/dist/tools.js ADDED
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCurrentRequestId = getCurrentRequestId;
4
+ exports.runWithContext = runWithContext;
5
+ exports.wrapTool = wrapTool;
6
+ exports.validateToolName = validateToolName;
7
+ exports.sanitizeError = sanitizeError;
8
+ exports.sanitizePayload = sanitizePayload;
9
+ const async_hooks_1 = require("async_hooks");
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+ /** Hard limit for serialized tool_input/tool_output JSON, in UTF-8 bytes. */
14
+ const MAX_PAYLOAD_BYTES = 256 * 1024; // 256 KB
15
+ /** Hard limit for tool error string, in UTF-8 bytes. */
16
+ const MAX_ERROR_BYTES = 1900;
17
+ /** Hard limit for tool_name, in UTF-8 bytes. */
18
+ const MAX_TOOL_NAME_BYTES = 200;
19
+ const toolContext = new async_hooks_1.AsyncLocalStorage();
20
+ // ---------------------------------------------------------------------------
21
+ // Internal: context-aware request_id resolution
22
+ // ---------------------------------------------------------------------------
23
+ /** Returns the requestId from the surrounding withContext block, or undefined. */
24
+ function getCurrentRequestId() {
25
+ return toolContext.getStore()?.requestId;
26
+ }
27
+ /** Runs `fn` inside an async context with the given requestId attached. */
28
+ function runWithContext(ctx, fn) {
29
+ return toolContext.run(ctx, fn);
30
+ }
31
+ /**
32
+ * Wraps a function so every call records an `agent.tool_call` event with
33
+ * timing, args, result, and any error.
34
+ *
35
+ * The wrapper auto-times, JSON-serializes args/result, and captures the
36
+ * surrounding `withContext` requestId (if any). Errors propagate to the caller
37
+ * after recording — wrapping is observation, not error swallowing.
38
+ */
39
+ function wrapTool(config, toolName, fn, options) {
40
+ return async (...args) => {
41
+ const startedAt = new Date().toISOString();
42
+ const start = Date.now();
43
+ let result;
44
+ let error = null;
45
+ try {
46
+ result = await Promise.resolve(fn(...args));
47
+ const durationMs = Date.now() - start;
48
+ // Fire-and-forget recording so the wrapped function's latency
49
+ // isn't affected by SignalVault's network call.
50
+ void config
51
+ .recordFn({
52
+ toolName,
53
+ toolInput: serializeArgs(args),
54
+ toolOutput: result,
55
+ durationMs,
56
+ startedAt,
57
+ requestId: getCurrentRequestId() ?? null,
58
+ metadata: options?.metadata,
59
+ })
60
+ .catch((err) => {
61
+ if (config.debug)
62
+ console.error('[SignalVault] tool record failed:', err);
63
+ });
64
+ return result;
65
+ }
66
+ catch (e) {
67
+ const durationMs = Date.now() - start;
68
+ error = e instanceof Error ? e.message : String(e);
69
+ void config
70
+ .recordFn({
71
+ toolName,
72
+ toolInput: serializeArgs(args),
73
+ toolOutput: null,
74
+ durationMs,
75
+ error,
76
+ startedAt,
77
+ requestId: getCurrentRequestId() ?? null,
78
+ metadata: options?.metadata,
79
+ })
80
+ .catch((err) => {
81
+ if (config.debug)
82
+ console.error('[SignalVault] tool record failed:', err);
83
+ });
84
+ throw e;
85
+ }
86
+ };
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // Sanitization helpers
90
+ // ---------------------------------------------------------------------------
91
+ /**
92
+ * Returns the byte length of a string when encoded as UTF-8.
93
+ * Used to enforce server-side byte limits accurately on the client.
94
+ */
95
+ function byteLength(s) {
96
+ return Buffer.byteLength(s, 'utf8');
97
+ }
98
+ /**
99
+ * Truncates a string to `max` UTF-8 bytes, preserving multi-byte boundaries.
100
+ * Appends a "(…N bytes truncated)" marker so the receiver knows truncation
101
+ * happened rather than seeing a silently shortened string.
102
+ */
103
+ function truncateString(s, max) {
104
+ if (byteLength(s) <= max)
105
+ return s;
106
+ const marker = (n) => ` …(truncated ${n} bytes)`;
107
+ // Reserve room for the marker — sized to fit the worst-case marker.
108
+ const reserve = byteLength(marker(99999999));
109
+ const buf = Buffer.from(s, 'utf8');
110
+ const truncatedBytes = buf.byteLength - (max - reserve);
111
+ const sliced = buf.subarray(0, max - reserve).toString('utf8');
112
+ // Cleanup: drop a trailing replacement char if we cut a multi-byte sequence.
113
+ const cleaned = sliced.endsWith('�') ? sliced.slice(0, -1) : sliced;
114
+ return cleaned + marker(truncatedBytes);
115
+ }
116
+ /** Validates and normalizes a tool name. Throws if missing or non-string. */
117
+ function validateToolName(name) {
118
+ if (typeof name !== 'string' || name.length === 0) {
119
+ throw new Error('[SignalVault] toolName must be a non-empty string');
120
+ }
121
+ return byteLength(name) > MAX_TOOL_NAME_BYTES
122
+ ? truncateString(name, MAX_TOOL_NAME_BYTES)
123
+ : name;
124
+ }
125
+ /** Truncates an error string to the server-accepted byte limit. */
126
+ function sanitizeError(err) {
127
+ if (err === null || err === undefined)
128
+ return undefined;
129
+ const s = typeof err === 'string' ? err : String(err);
130
+ return truncateString(s, MAX_ERROR_BYTES);
131
+ }
132
+ /**
133
+ * Returns a JSON-safe version of `value`, capped at MAX_PAYLOAD_BYTES.
134
+ *
135
+ * - Circular references, BigInt, functions, Symbols → replaced with a marker
136
+ * object so the audit event is still useful instead of dropped silently.
137
+ * - Oversized values → truncated; the marker `_signalvault_truncated` is set
138
+ * so the receiver can detect partial captures.
139
+ *
140
+ * Never throws — sanitization failures degrade gracefully.
141
+ */
142
+ function sanitizePayload(value) {
143
+ if (value === undefined)
144
+ return null;
145
+ let serialized;
146
+ try {
147
+ serialized = JSON.stringify(value, replacer());
148
+ }
149
+ catch (e) {
150
+ return {
151
+ _signalvault_serialization_error: e instanceof Error ? e.message : String(e),
152
+ };
153
+ }
154
+ // JSON.stringify returns undefined for top-level functions/Symbols/undefined.
155
+ if (serialized === undefined) {
156
+ return { _signalvault_serialization_error: 'value is not JSON-serializable' };
157
+ }
158
+ if (byteLength(serialized) <= MAX_PAYLOAD_BYTES) {
159
+ // Re-parse so the body is sent as a structured value, not a JSON string.
160
+ try {
161
+ return JSON.parse(serialized);
162
+ }
163
+ catch {
164
+ return { _signalvault_serialization_error: 're-parse failed' };
165
+ }
166
+ }
167
+ // Oversized: include the original byte size so debugging is possible.
168
+ return {
169
+ _signalvault_truncated: true,
170
+ _signalvault_original_bytes: byteLength(serialized),
171
+ _signalvault_max_bytes: MAX_PAYLOAD_BYTES,
172
+ preview: truncateString(serialized, MAX_PAYLOAD_BYTES - 200),
173
+ };
174
+ }
175
+ /**
176
+ * JSON.stringify replacer that handles circular refs, BigInts, and other
177
+ * non-serializable values by substituting markers instead of throwing.
178
+ */
179
+ function replacer() {
180
+ const seen = new WeakSet();
181
+ return function (_key, value) {
182
+ if (typeof value === 'bigint')
183
+ return `${value.toString()}n`;
184
+ if (typeof value === 'function')
185
+ return '[Function]';
186
+ if (typeof value === 'symbol')
187
+ return value.toString();
188
+ if (value !== null && typeof value === 'object') {
189
+ if (seen.has(value))
190
+ return '[Circular]';
191
+ seen.add(value);
192
+ }
193
+ return value;
194
+ };
195
+ }
196
+ /**
197
+ * If the wrapped function takes a single argument, store that value directly
198
+ * (the common case). Otherwise store the whole args array. This avoids storing
199
+ * `[{...}]` when callers pass a single object — better for dashboard rendering.
200
+ */
201
+ function serializeArgs(args) {
202
+ if (args.length === 0)
203
+ return null;
204
+ if (args.length === 1)
205
+ return args[0];
206
+ return args;
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalvaultio/node",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "SignalVault SDK - AI audit logs and guardrails for safe production AI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -27,6 +27,9 @@
27
27
  ],
28
28
  "author": "SignalVault <support@signalvault.io>",
29
29
  "license": "MIT",
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
30
33
  "repository": {
31
34
  "type": "git",
32
35
  "url": "git+https://github.com/signalvaultio/signalvault-node.git"