generalcontext 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
@@ -1,6 +1,8 @@
1
1
  # generalcontext
2
2
 
3
- Record your agent's model calls for General Context. Your agent never waits on us, and a failure on our side is logged and dropped, never thrown. No runtime dependencies. Supports OpenAI (`chat.completions`, `responses`) and Anthropic (`messages`), streamed or not.
3
+ Record your AI agent's model calls for [General Context](https://generalcontextlabs.com/docs). We read your agent's production conversations and find where it goes wrong, and each failure you confirm becomes an RL and eval task built from the conversation. Your tasks together are your benchmark.
4
+
5
+ Recording never gets in your agent's way: calls are sent in the background, a failure on our side is logged and dropped, never thrown, and there are no runtime dependencies.
4
6
 
5
7
  ## Install
6
8
 
@@ -10,110 +12,53 @@ npm install generalcontext
10
12
 
11
13
  Set `GENERALCONTEXT_API_KEY` to the key from your workspace's Connect page.
12
14
 
13
- ## Initialize
15
+ ## Record
14
16
 
15
- Call once per process, before your agent makes model calls. Every OpenAI and Anthropic client is recorded, including ones created earlier.
17
+ Call `initialize()` once per process, before your agent makes model calls. Pass the client modules you use: it is required whenever a bundler is involved.
16
18
 
17
19
  ```ts
18
20
  import OpenAI from 'openai';
19
21
  import Anthropic from '@anthropic-ai/sdk';
20
- import { initialize } from 'generalcontext';
22
+ import { initialize, withSession, flush } from 'generalcontext';
21
23
 
22
24
  initialize({ appName: 'support-agent', instrumentModules: { openAI: OpenAI, anthropic: Anthropic } });
23
- ```
24
-
25
- ## Configuration
26
-
27
- | Option | Default | |
28
- | - | - | - |
29
- | `apiKey` | `GENERALCONTEXT_API_KEY` | |
30
- | `baseUrl` | `GENERALCONTEXT_BASE_URL`, else `https://generalcontextlabs.com` | The option wins over the env var. Must be `https://`, except `http://localhost`, `127.0.0.1` or `[::1]`. Other URLs are refused and nothing is sent. |
31
- | `appName` | none | Sent with every batch. |
32
- | `redact` | `false` | See [Redaction](#redaction). |
33
- | `beforeSend` | none | `(call) => call \| null`. Runs at send time on each call, before redaction. Return the call (edited or not) to send it, or `null` to drop it. A throw drops the call. |
34
- | `instrumentModules` | installed `openai` and `@anthropic-ai/sdk` | Pass these whenever a bundler is involved. |
35
- | `instrumentFetch` | `false` | SDK clients are always recorded. Set `true` to also record raw `fetch` POSTs to any `/chat/completions` URL. |
36
- | `disableBatch` | `false` | Send each call right away. |
37
-
38
- | Env var | |
39
- | - | - |
40
- | `GENERALCONTEXT_API_KEY` | API key, if `apiKey` is not passed. |
41
- | `GENERALCONTEXT_BASE_URL` | Collector URL, if `baseUrl` is not passed. |
42
- | `GENERALCONTEXT_REDACT_SALT` | Secret that keeps redaction placeholders stable across processes. |
43
- | `GENERALCONTEXT_DISABLED` | `1`, `true` or `yes` makes `initialize()` do nothing. |
44
-
45
- Requests never follow redirects. A call over 4 MB is dropped, and the queue holds at most 5000 calls or 64 MB. Pending calls are flushed when the process is about to exit, for at most 2 seconds.
46
-
47
- ## Proxies
48
-
49
- Node's built-in `fetch` ignores `HTTPS_PROXY` by default. On Node 24+, set `NODE_USE_ENV_PROXY=1`. On older versions, install a global dispatcher (for example undici's `setGlobalDispatcher(new EnvHttpProxyAgent())`). If your proxy inspects TLS, point `NODE_EXTRA_CA_CERTS` at its CA certificate.
50
25
 
51
- ## Next.js
26
+ // Group every call in one conversation under the id you already use for it
27
+ await withSession({ sessionId: conversationId, userId: customerId, channel: 'chat' }, () => handle(message));
52
28
 
53
- Initialize in `instrumentation.ts` at the project root, not in a route file:
54
-
55
- ```ts
56
- export async function register() {
57
- if (process.env.NEXT_RUNTIME === 'nodejs') {
58
- const { initialize } = await import('generalcontext');
59
- const { OpenAI } = await import('openai');
60
- const anthropic = await import('@anthropic-ai/sdk');
61
- initialize({ appName: 'my-agent', instrumentModules: { openAI: OpenAI, anthropic } });
62
- }
63
- }
64
- ```
65
-
66
- Keep the SDKs out of the bundle so routes use the instance we patched:
67
-
68
- ```ts
69
- // next.config.ts
70
- export default { serverExternalPackages: ['openai', '@anthropic-ai/sdk'] };
29
+ await flush(); // before a serverless function, job or script exits
71
30
  ```
72
31
 
73
- Use the Node.js runtime. The Edge runtime is not supported.
74
-
75
- ## Sessions and tasks
76
-
77
- Tag calls with your own ids. Tags carry across `await`.
32
+ If your app serves several companies of its own, pass `account` to `withSession` (the id or name you already use for that company) so conversations are grouped by account.
78
33
 
79
- ```ts
80
- import { withSession, task } from 'generalcontext';
81
-
82
- await withSession({ sessionId: conversationId, userId: 'u_42', channel: 'chat' }, async () => {
83
- await task('triage', () => client.chat.completions.create({ ... }));
84
- });
85
- ```
86
-
87
- `withSession(conversationId, fn)` also works. `conversation({ id, ... }, fn)` is the older name.
88
-
89
- ## Serverless and scripts
90
-
91
- Calls send in the background. Before a function or script exits:
92
-
93
- ```ts
94
- await flush(); // never throws
95
- ```
34
+ | Provider | Package | `instrumentModules` key |
35
+ | - | - | - |
36
+ | OpenAI, including Azure OpenAI | `openai` | `openAI` |
37
+ | Anthropic, including Bedrock, Vertex and beta | `@anthropic-ai/sdk` | `anthropic` |
38
+ | Google Gemini | `@google/genai` | `googleGenAI` |
39
+ | AWS Bedrock Converse | `@aws-sdk/client-bedrock-runtime` | `bedrockRuntime` |
40
+ | Anything else | Send [OpenTelemetry](https://generalcontextlabs.com/docs/opentelemetry) | |
96
41
 
97
- ## Outcomes and events
42
+ **Voice (OpenAI Realtime).** `OpenAIRealtimeWS` (`openai/realtime/ws`) and `OpenAIRealtimeWebSocket`
43
+ (`openai/realtime/websocket`) connections are recorded too. Create the connection inside `withSession(...)` and
44
+ the whole call lands in that conversation. Each `response.done` is recorded as one `openai-realtime` call shaped
45
+ like a Responses call: the instructions and tools, the conversation so far as text (caller turns from the input
46
+ transcription, agent turns from the output transcript, tool calls and their results), and a `voice` field with
47
+ `speech_stopped_at`, `first_audio_at`, `interrupted` and `truncated_at_ms`. Turn on input audio transcription in
48
+ the session to record what the caller said. Audio is never recorded. Pass a connection to `wrap()` to record only
49
+ that one.
98
50
 
99
- Both flush pending calls first, so the session exists before the outcome or event arrives. Both return `true` when accepted and never throw.
51
+ Node 18 to 24, Node.js runtime only (not Edge). Supported client versions, Next.js, proxies, redaction, outcomes and events, and every option: [SDK reference](https://generalcontextlabs.com/docs/sdk).
100
52
 
101
- ```ts
102
- await reportOutcome(conversationId, 'fail', 'customer reopened the ticket');
53
+ ## Command line
103
54
 
104
- await logEvent({ sessionId: conversationId, eventType: 'order.refunded', eventId: `refund-${refundId}`, properties: { amount: 42.5 } });
105
- await logEvent({ sessionId: conversationId, eventType: 'handoff.to_human', eventId: `handoff-${ticketId}` });
55
+ ```sh
56
+ npx generalcontext verify # the key works, and when the last call arrived
57
+ npx generalcontext verify --session conv-123 # that one conversation arrived
58
+ npx generalcontext benchmark pull --out benchmark.json
106
59
  ```
107
60
 
108
- Pass a stable `eventId` so retries dedupe. `properties` are flat strings, numbers and booleans. `timestamp` defaults to now.
109
-
110
- ## Redaction
111
-
112
- `initialize({ redact: true })` replaces emails, phone numbers and card or SSN-like numbers with placeholders such as `<email:3f9a1c>` before anything leaves your process. Set `GENERALCONTEXT_REDACT_SALT` to one secret in every process so the same value gets the same placeholder. Or pass your own `(text) => text`. Redaction also applies to `reportOutcome` and `logEvent`, so a redacted session id still matches its calls.
113
-
114
- ## One client only
115
-
116
- `wrap(client, { redact })` records a single client instance without patching the SDK. Its `redact` applies to that client only. If `initialize()` already patched the SDK, the client is recorded with the `initialize()` settings.
61
+ Running the benchmark is in the Python package. See the [command line docs](https://generalcontextlabs.com/docs/cli).
117
62
 
118
63
  ## License
119
64
 
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // Copyright 2026 General Context Labs, Inc.
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // generalcontext verify [--session ID] [--json]
5
+ // generalcontext benchmark pull [--out FILE]
6
+ // Exit 0 on success, 1 on a failed check, 2 on a usage or configuration error. No runtime dependencies.
7
+ import { writeFileSync } from 'node:fs';
8
+ const DEFAULT_BASE_URL = 'https://generalcontextlabs.com';
9
+ const TIMEOUT_MS = 30_000;
10
+ const USAGE = `Usage:
11
+ generalcontext verify [--session ID] [--json]
12
+ generalcontext benchmark pull [--out FILE]
13
+ Uses GENERALCONTEXT_API_KEY and GENERALCONTEXT_BASE_URL.`;
14
+ const PYTHON_HINT = 'Run the benchmark with Python: pip install generalcontext && generalcontext evals run ...';
15
+ class Exit extends Error {
16
+ code;
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.code = code;
20
+ }
21
+ }
22
+ const trimSlashes = (url) => { let end = url.length; while (end > 0 && url[end - 1] === '/')
23
+ end--; return url.slice(0, end); };
24
+ // Same rule as the SDK: https, or plain http to this machine only.
25
+ function secureUrl(url) {
26
+ try {
27
+ const u = new URL(url);
28
+ return u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname));
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function settings() {
35
+ const key = (process.env.GENERALCONTEXT_API_KEY ?? '').trim();
36
+ if (!key)
37
+ throw new Exit(2, 'GENERALCONTEXT_API_KEY is not set. Create a key on the API keys page and export it.');
38
+ const base = trimSlashes((process.env.GENERALCONTEXT_BASE_URL ?? '').trim() || DEFAULT_BASE_URL);
39
+ if (!secureUrl(base))
40
+ throw new Exit(2, `GENERALCONTEXT_BASE_URL ${base} is not https. Use https, or http://localhost for local development.`);
41
+ return { key, base };
42
+ }
43
+ async function get(path) {
44
+ const { key, base } = settings();
45
+ let response;
46
+ try {
47
+ response = await fetch(`${base}/api/platform-collect${path}`, {
48
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
49
+ redirect: 'error', // never forward the key to another host
50
+ signal: AbortSignal.timeout(TIMEOUT_MS),
51
+ });
52
+ }
53
+ catch (error) {
54
+ throw new Exit(1, `Could not reach ${base}: ${error.message}`);
55
+ }
56
+ const text = await response.text();
57
+ let body = null;
58
+ try {
59
+ body = text ? JSON.parse(text) : null;
60
+ }
61
+ catch { /* not JSON */ }
62
+ return { status: response.status, body };
63
+ }
64
+ const errorText = (status, body) => typeof body?.error === 'string' ? body.error : `HTTP ${status}`;
65
+ function failed(status, body) {
66
+ if (status === 401)
67
+ throw new Exit(1, 'The API key was not accepted. Check GENERALCONTEXT_API_KEY.');
68
+ throw new Exit(1, `Request failed: ${errorText(status, body)}`);
69
+ }
70
+ function parseFlags(args, known) {
71
+ const out = {};
72
+ for (let i = 0; i < args.length; i++) {
73
+ let arg = args[i], value;
74
+ const eq = arg.indexOf('=');
75
+ if (arg.startsWith('--') && eq > 0) {
76
+ value = arg.slice(eq + 1);
77
+ arg = arg.slice(0, eq);
78
+ }
79
+ const kind = known[arg];
80
+ if (!kind)
81
+ throw new Exit(2, `Unknown argument ${arg}.\n${USAGE}`);
82
+ if (kind === 'bool') {
83
+ out[arg] = true;
84
+ continue;
85
+ }
86
+ value ??= args[++i];
87
+ if (value === undefined || value === '')
88
+ throw new Exit(2, `${arg} needs a value.\n${USAGE}`);
89
+ out[arg] = value;
90
+ }
91
+ return out;
92
+ }
93
+ const scopeWords = { ingest: 'sends traffic', evals: 'runs the benchmark' };
94
+ async function verify(args) {
95
+ const flags = parseFlags(args, { '--session': 'value', '--json': 'bool' });
96
+ const session = flags['--session'];
97
+ const { status, body } = await get(`/status${session ? `?session=${encodeURIComponent(session)}` : ''}`);
98
+ if (status !== 200 || !body)
99
+ failed(status, body);
100
+ const found = session ? Boolean(body.session?.found) : true;
101
+ if (flags['--json']) {
102
+ console.log(JSON.stringify(body, null, 2));
103
+ return found ? 0 : 1;
104
+ }
105
+ const scopes = Array.isArray(body.key?.scopes) ? body.key.scopes : [];
106
+ const label = body.key?.label ? ` "${body.key.label}"` : '';
107
+ console.log(`Workspace ${body.workspace}. Key${label} ${scopes.length ? scopes.map(s => scopeWords[s] ?? s).join(' and ') : 'has no scopes'}.`);
108
+ console.log(body.last_call_at ? `Last call arrived ${body.last_call_at} from ${body.last_source ?? 'unknown'}.` : 'No calls have arrived yet.');
109
+ if (session) {
110
+ const s = body.session ?? {};
111
+ if (!found) {
112
+ console.log(`Conversation ${session}: not found yet.`);
113
+ return 1;
114
+ }
115
+ const models = Array.isArray(s.models) ? s.models : [];
116
+ console.log(`Conversation ${session}: arrived, ${s.calls ?? 0} call${s.calls === 1 ? '' : 's'}${models.length ? ` (${models.join(', ')})` : ''}.`);
117
+ }
118
+ return 0;
119
+ }
120
+ async function benchmark(args) {
121
+ const [sub, ...rest] = args;
122
+ if (sub !== 'pull')
123
+ throw new Exit(2, USAGE);
124
+ const flags = parseFlags(rest, { '--out': 'value' });
125
+ const out = flags['--out'] ?? 'benchmark.json';
126
+ const { status, body } = await get('/benchmark');
127
+ if (status === 403)
128
+ throw new Exit(1, errorText(status, body) === `HTTP 403`
129
+ ? 'This key cannot download the benchmark. On API keys, create a key that can run the benchmark.' : errorText(status, body));
130
+ if (status !== 200 || !body)
131
+ failed(status, body);
132
+ try {
133
+ writeFileSync(out, JSON.stringify(body, null, 2) + '\n');
134
+ }
135
+ catch (error) {
136
+ throw new Exit(1, `Could not write ${out}: ${error.message}`);
137
+ }
138
+ const tasks = Array.isArray(body.tasks) ? body.tasks : [];
139
+ const splits = new Map();
140
+ for (const t of tasks) {
141
+ const s = typeof t?.split === 'string' && t.split ? t.split : 'unsplit';
142
+ splits.set(s, (splits.get(s) ?? 0) + 1);
143
+ }
144
+ const bySplit = [...splits].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([s, n]) => `${n} ${s}`).join(', ');
145
+ console.log(`Wrote ${out}: ${tasks.length} task${tasks.length === 1 ? '' : 's'}${bySplit ? ` (${bySplit})` : ''}.`);
146
+ return 0;
147
+ }
148
+ async function main(argv) {
149
+ const [command, ...args] = argv;
150
+ try {
151
+ if (command === 'verify')
152
+ return await verify(args);
153
+ if (command === 'benchmark')
154
+ return await benchmark(args);
155
+ if (command === 'evals') {
156
+ console.error(PYTHON_HINT);
157
+ return 2;
158
+ }
159
+ if (command === '-h' || command === '--help' || command === 'help') {
160
+ console.log(USAGE);
161
+ return 0;
162
+ }
163
+ console.error(USAGE);
164
+ return 2;
165
+ }
166
+ catch (error) {
167
+ if (error instanceof Exit) {
168
+ (error.code === 0 ? console.log : console.error)(error.message);
169
+ return error.code;
170
+ }
171
+ console.error(`generalcontext: ${error?.message ?? error}`);
172
+ return 1;
173
+ }
174
+ }
175
+ main(process.argv.slice(2)).then(code => { process.exitCode = code; });
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- export declare const VERSION = "0.2.0";
1
+ export declare const VERSION = "0.3.0";
2
2
  type Metadata = Record<string, string | number | boolean>;
3
3
  export type Session = {
4
4
  sessionId: string;
5
5
  userId?: string;
6
6
  channel?: string;
7
7
  product?: string;
8
+ account?: string;
8
9
  metadata?: Metadata;
9
10
  };
10
11
  type Tags = {
@@ -12,9 +13,17 @@ type Tags = {
12
13
  user_id?: string;
13
14
  channel?: string;
14
15
  product?: string;
16
+ account?: string;
15
17
  metadata?: Metadata;
16
18
  };
17
- type Api = 'openai-chat' | 'openai-responses' | 'anthropic';
19
+ type Api = 'openai-chat' | 'openai-responses' | 'anthropic' | 'gemini' | 'bedrock-converse' | 'openai-realtime';
20
+ /** Voice timing on 'openai-realtime' calls. Timestamps are ISO strings, null when not seen. */
21
+ export type VoiceTiming = {
22
+ speech_stopped_at: string | null;
23
+ first_audio_at: string | null;
24
+ interrupted: boolean;
25
+ truncated_at_ms?: number;
26
+ };
18
27
  export type Call = {
19
28
  api: Api;
20
29
  conversation: Tags | null;
@@ -24,6 +33,7 @@ export type Call = {
24
33
  started_at: string;
25
34
  ended_at: string;
26
35
  error: string | null;
36
+ voice?: VoiceTiming;
27
37
  };
28
38
  type Scrub = (text: string) => string;
29
39
  type BeforeSend = (call: Call) => Call | null | undefined;
@@ -36,18 +46,29 @@ export type InitializeOptions = {
36
46
  redact?: boolean | Scrub;
37
47
  /** Runs at send time on each call, before redaction. Return the call to send, or null to drop it. */
38
48
  beforeSend?: BeforeSend;
39
- /** SDK classes or modules to patch, e.g. { openAI: OpenAI, anthropic: Anthropic }. */
40
- instrumentModules?: {
41
- openAI?: unknown;
42
- anthropic?: unknown;
43
- };
49
+ /**
50
+ * SDK classes or modules to patch, e.g. { openAI: OpenAI, anthropic: Anthropic }. Also accepts
51
+ * googleGenAI (the '@google/genai' module) and bedrockRuntime (the '@aws-sdk/client-bedrock-runtime' module).
52
+ */
53
+ instrumentModules?: Modules;
44
54
  /** Also record raw fetch POSTs to any URL ending /chat/completions. Default false. */
45
55
  instrumentFetch?: boolean;
46
56
  /** Send each call right away instead of batching. */
47
57
  disableBatch?: boolean;
48
58
  };
59
+ type Modules = {
60
+ openAI?: unknown;
61
+ anthropic?: unknown;
62
+ googleGenAI?: unknown;
63
+ bedrockRuntime?: unknown;
64
+ };
49
65
  /** Configure recording once per process, before your agent makes model calls. */
50
66
  export declare function initialize(options?: InitializeOptions): void;
67
+ /**
68
+ * Resolves once initialize() has patched every SDK build it found, including the ES module builds it loads
69
+ * asynchronously. Await it when a model call may run before the app's next await after initialize(). Never rejects.
70
+ */
71
+ export declare function ready(): Promise<void>;
51
72
  /** Tag every call made inside `run` with one session. `conversation` is the same function. */
52
73
  export declare function withSession<T>(session: Session | string, run: () => T): T;
53
74
  /** Older name for withSession. Accepts { id } as well as { sessionId }. */
@@ -55,12 +76,34 @@ export declare function conversation<T>(tags: Session | {
55
76
  id: string;
56
77
  channel?: string;
57
78
  product?: string;
79
+ account?: string;
58
80
  metadata?: Metadata;
59
81
  } | string, run: () => T): T;
60
82
  /** Tag every call made inside `run` with a task name, such as one step of your agent loop. */
61
83
  export declare function task<T>(name: string, run: () => T): T;
62
- /** Wait until recorded calls are sent. Call before a serverless function or script exits. Never throws. */
63
- export declare function flush(): Promise<void>;
84
+ /**
85
+ * The copy of a call that is recorded. Never changes `value`, and never touches what the caller sends to the provider.
86
+ * Agents resend the whole run on every call, so without this, later calls pass the per-call limit and are lost.
87
+ *
88
+ * - Inline binary becomes a placeholder that keeps the shape: a base64 data URL keeps its prefix
89
+ * (`data:image/png;base64,[omitted N bytes sha256:ab12cd34ef56]`), and a base64 `data`, `result` or
90
+ * `b64_json` field (Anthropic `source.data`, Gemini `inlineData.data`, OpenAI `input_audio.data`, Responses
91
+ * `image_generation_call.result`) becomes `[omitted N bytes sha256:...]`. N is the length of the base64 text
92
+ * and the hash is the first 12 hex digits of the sha256 of that text, so the same image gets the same placeholder
93
+ * in every call. Media types stay. Binary is never recorded: it does not help review and it is a privacy risk.
94
+ * - Any string over 200,000 characters keeps its first 100,000 and last 20,000 characters around
95
+ * `\n[... clipped N characters ...]\n`.
96
+ *
97
+ * Runs before beforeSend and redaction. A call still over the limit afterwards is dropped.
98
+ */
99
+ export declare function compactForRecording(value: unknown, key?: string, depth?: number): unknown;
100
+ export declare function maskSecrets(text: string): string;
101
+ /**
102
+ * Wait until recorded calls are sent, for at most `timeoutMs` (default 10 s). Call before a serverless
103
+ * function or script exits. Resolves true when the queue was sent, false at the deadline. Never throws.
104
+ * Sending continues in the background after the deadline.
105
+ */
106
+ export declare function flush(timeoutMs?: number): Promise<boolean>;
64
107
  /** Mark how a session ended. Returns true when the platform accepted it. Never throws. */
65
108
  export declare function reportOutcome(sessionId: string, outcome: 'success' | 'fail', note?: string): Promise<boolean>;
66
109
  export type AppEvent = {