@hexis-ai/engram-sdk 0.1.2 → 0.1.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/dist/client.d.ts CHANGED
@@ -16,6 +16,25 @@ export interface EngramOptions {
16
16
  batchSize?: number;
17
17
  /** Hook invoked on transport errors. Default: console.error. */
18
18
  onError?: (err: unknown) => void;
19
+ /**
20
+ * Header name to carry the api key. Defaults to "Authorization" with a
21
+ * `Bearer <key>` value for backward compatibility. Set to "x-api-key"
22
+ * when the host adds its own `Authorization` (e.g. a Cloud Run ID token
23
+ * for service-to-service IAM).
24
+ */
25
+ apiKeyHeader?: "authorization" | "x-api-key";
26
+ /**
27
+ * Async hook resolved per-request that returns headers to attach. Use
28
+ * for short-lived credentials like Cloud Run identity tokens.
29
+ */
30
+ authHeaders?: () => Promise<Record<string, string>> | Record<string, string>;
31
+ /**
32
+ * Max retry attempts for a failed event batch (transient transport errors).
33
+ * Default 4 (~30s total with exponential backoff). 0 disables retries.
34
+ */
35
+ maxRetries?: number;
36
+ /** Base backoff in ms; doubled each attempt. Default 500. */
37
+ retryBackoffMs?: number;
19
38
  }
20
39
  export interface RecordStepInput {
21
40
  tool: string;
@@ -54,6 +73,10 @@ export declare class Engram {
54
73
  private readonly flushIntervalMs;
55
74
  private readonly batchSize;
56
75
  private readonly onError;
76
+ private readonly apiKeyHeader;
77
+ private readonly authHeaders?;
78
+ readonly maxRetries: number;
79
+ readonly retryBackoffMs: number;
57
80
  constructor(opts: EngramOptions);
58
81
  /** Begin a new session. Returns a handle for buffering events. */
59
82
  startSession(init?: SessionInit): Promise<EngramSession>;
@@ -90,7 +113,12 @@ export declare class EngramSession {
90
113
  setTitle(title: string): void;
91
114
  /** Mark the session ended and flush buffered events. */
92
115
  end(): Promise<void>;
93
- /** Force a flush regardless of batch size / timer. */
116
+ /**
117
+ * Force a flush regardless of batch size / timer. Transient failures
118
+ * are retried with exponential backoff up to `maxRetries`. If every
119
+ * attempt fails the events are returned to the head of the buffer so
120
+ * the next flush picks them up.
121
+ */
94
122
  flush(): Promise<void>;
95
123
  private enqueue;
96
124
  }
package/dist/client.js CHANGED
@@ -7,6 +7,10 @@ export class Engram {
7
7
  flushIntervalMs;
8
8
  batchSize;
9
9
  onError;
10
+ apiKeyHeader;
11
+ authHeaders;
12
+ maxRetries;
13
+ retryBackoffMs;
10
14
  constructor(opts) {
11
15
  if (!opts.apiKey)
12
16
  throw new Error("Engram: apiKey is required");
@@ -18,6 +22,10 @@ export class Engram {
18
22
  this.flushIntervalMs = opts.flushIntervalMs ?? 2000;
19
23
  this.batchSize = opts.batchSize ?? 32;
20
24
  this.onError = opts.onError ?? ((e) => console.error("[engram]", e));
25
+ this.apiKeyHeader = opts.apiKeyHeader ?? "authorization";
26
+ this.authHeaders = opts.authHeaders;
27
+ this.maxRetries = opts.maxRetries ?? 4;
28
+ this.retryBackoffMs = opts.retryBackoffMs ?? 500;
21
29
  }
22
30
  /** Begin a new session. Returns a handle for buffering events. */
23
31
  async startSession(init = {}) {
@@ -53,12 +61,18 @@ export class Engram {
53
61
  return { flushIntervalMs: this.flushIntervalMs, batchSize: this.batchSize, onError: this.onError };
54
62
  }
55
63
  async request(method, path, body) {
64
+ const headers = { "content-type": "application/json" };
65
+ if (this.apiKeyHeader === "authorization") {
66
+ headers["authorization"] = `Bearer ${this.apiKey}`;
67
+ }
68
+ else {
69
+ headers["x-api-key"] = this.apiKey;
70
+ }
71
+ if (this.authHeaders)
72
+ Object.assign(headers, await this.authHeaders());
56
73
  const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
57
74
  method,
58
- headers: {
59
- "content-type": "application/json",
60
- authorization: `Bearer ${this.apiKey}`,
61
- },
75
+ headers,
62
76
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
63
77
  });
64
78
  if (!res.ok) {
@@ -128,7 +142,12 @@ export class EngramSession {
128
142
  });
129
143
  await this.flush();
130
144
  }
131
- /** Force a flush regardless of batch size / timer. */
145
+ /**
146
+ * Force a flush regardless of batch size / timer. Transient failures
147
+ * are retried with exponential backoff up to `maxRetries`. If every
148
+ * attempt fails the events are returned to the head of the buffer so
149
+ * the next flush picks them up.
150
+ */
132
151
  async flush() {
133
152
  if (this.flushTimer) {
134
153
  clearTimeout(this.flushTimer);
@@ -139,14 +158,32 @@ export class EngramSession {
139
158
  if (this.buffer.length === 0)
140
159
  return;
141
160
  const events = this.buffer.splice(0);
142
- this.inFlight = this.engram
143
- .sendBatch(this.id, { events })
144
- .catch((e) => {
145
- this.engram.config.onError(e);
161
+ const { maxRetries, retryBackoffMs, onError } = {
162
+ maxRetries: this.engram.maxRetries,
163
+ retryBackoffMs: this.engram.retryBackoffMs,
164
+ onError: this.engram.config.onError,
165
+ };
166
+ this.inFlight = (async () => {
167
+ let lastErr;
168
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
169
+ try {
170
+ await this.engram.sendBatch(this.id, { events });
171
+ return;
172
+ }
173
+ catch (e) {
174
+ lastErr = e;
175
+ onError(e);
176
+ if (attempt < maxRetries) {
177
+ const delay = retryBackoffMs * 2 ** attempt;
178
+ await new Promise((r) => setTimeout(r, delay));
179
+ }
180
+ }
181
+ }
182
+ // All retries exhausted — return events to the buffer head so a
183
+ // future flush (or session.end()) can take another swing.
146
184
  this.buffer.unshift(...events);
147
- throw e;
148
- })
149
- .finally(() => {
185
+ throw lastErr;
186
+ })().finally(() => {
150
187
  this.inFlight = null;
151
188
  });
152
189
  await this.inFlight;
package/dist/extract.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Resource id format follows engram convention: `${service}:${id-or-url}`.
10
10
  */
11
- export type ReferenceService = "linear" | "notion" | "web" | "github";
11
+ export type ReferenceService = "linear" | "notion" | "web" | "github" | "slack" | "gmail";
12
12
  export type ReferenceAction = "read" | "write";
13
13
  export interface RefCandidate {
14
14
  service: ReferenceService;
package/dist/extract.js CHANGED
@@ -10,13 +10,32 @@
10
10
  */
11
11
  const isWebSearch = (n) => n === "WebSearch" || n === "web_search";
12
12
  const isWebFetch = (n) => n === "WebFetch" || n === "web_fetch";
13
+ /**
14
+ * Tool names whose effect is a write (mutation). Defaults to read.
15
+ * Same map for all vendors — names are namespaced enough.
16
+ */
13
17
  const WRITE_TOOLS = {
18
+ // linear (native + mcp)
14
19
  create_issue: "write",
15
20
  update_issue: "write",
21
+ // notion mcp
16
22
  "notion-create-pages": "write",
17
23
  "notion-update-page": "write",
18
24
  "notion-create-database": "write",
19
25
  "notion-create-comment": "write",
26
+ // slack mcp
27
+ slack_post_message: "write",
28
+ slack_reply_to_thread: "write",
29
+ slack_add_reaction: "write",
30
+ // gmail mcp
31
+ gmail_send: "write",
32
+ gmail_send_message: "write",
33
+ gmail_draft_message: "write",
34
+ // github mcp
35
+ github_create_issue: "write",
36
+ github_create_pull_request: "write",
37
+ github_create_comment: "write",
38
+ github_add_pr_review_comment: "write",
20
39
  };
21
40
  function getAction(toolName) {
22
41
  return WRITE_TOOLS[toolName] ?? "read";
@@ -51,6 +70,12 @@ export function extractReferences(vendor, toolName, input, result) {
51
70
  return extractLinearRefs(toolName, input, result);
52
71
  if (vendor === "notion")
53
72
  return extractNotionRefs(toolName, input, result);
73
+ if (vendor === "slack")
74
+ return extractSlackRefs(toolName, input, result);
75
+ if (vendor === "gmail")
76
+ return extractGmailRefs(toolName, input, result);
77
+ if (vendor === "github")
78
+ return extractGithubRefs(toolName, input, result);
54
79
  return [];
55
80
  }
56
81
  /** Convenience: encode RefCandidate to engram resource id (`service:resource_id`). */
@@ -135,3 +160,83 @@ function extractWebRefs(toolName, input) {
135
160
  }
136
161
  return [];
137
162
  }
163
+ /**
164
+ * Slack MCP: input names follow `channel_id` / `channel` / `user_id` / `thread_ts`.
165
+ * Resource id encodes "C123" for channels, "U123" for users, "C123:1234.567"
166
+ * for thread anchors. Read-style tools (list_*, get_history, ...) produce
167
+ * a channel resource when one is named; post / reply produce write resources.
168
+ */
169
+ function extractSlackRefs(toolName, input, _result) {
170
+ const action = getAction(toolName);
171
+ const out = [];
172
+ const channel = (input.channel_id ?? input.channel);
173
+ const thread = (input.thread_ts ?? input.ts);
174
+ const user = (input.user_id ?? input.user);
175
+ if (channel && thread) {
176
+ out.push({ service: "slack", action, resource_id: `${channel}:${thread}` });
177
+ }
178
+ else if (channel) {
179
+ out.push({ service: "slack", action, resource_id: channel });
180
+ }
181
+ if (user) {
182
+ out.push({ service: "slack", action: "read", resource_id: user });
183
+ }
184
+ return out;
185
+ }
186
+ /**
187
+ * Gmail MCP: `messageId`, `threadId`, `query` for search. Threads are the
188
+ * stable identity in Gmail; we prefer thread id when present, fall back to
189
+ * message id. Search produces `query:<q>` so two searches for the same
190
+ * topic dedupe.
191
+ */
192
+ function extractGmailRefs(toolName, input, _result) {
193
+ const action = getAction(toolName);
194
+ const out = [];
195
+ const threadId = (input.threadId ?? input.thread_id);
196
+ const messageId = (input.messageId ?? input.message_id);
197
+ const query = input.query;
198
+ const to = input.to;
199
+ if (threadId) {
200
+ out.push({ service: "gmail", action, resource_id: `thread:${threadId}` });
201
+ }
202
+ else if (messageId) {
203
+ out.push({ service: "gmail", action, resource_id: `msg:${messageId}` });
204
+ }
205
+ if (action === "read" && query) {
206
+ out.push({ service: "gmail", action: "read", resource_id: `query:${query}` });
207
+ }
208
+ if (action === "write" && to) {
209
+ const recipients = Array.isArray(to) ? to : [to];
210
+ for (const r of recipients) {
211
+ out.push({ service: "gmail", action: "write", resource_id: `to:${r}` });
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+ /**
217
+ * GitHub MCP: `owner`, `repo`, `pull_number` / `issue_number` / `path`.
218
+ * PRs and issues are global identifiers within a repo so we encode as
219
+ * `<owner>/<repo>#<n>`. File paths become `<owner>/<repo>:<path>`.
220
+ */
221
+ function extractGithubRefs(toolName, input, _result) {
222
+ const action = getAction(toolName);
223
+ const owner = input.owner;
224
+ const repo = input.repo;
225
+ if (!owner || !repo)
226
+ return [];
227
+ const base = `${owner}/${repo}`;
228
+ const pullNumber = input.pull_number ?? input.pullNumber;
229
+ if (pullNumber != null) {
230
+ return [{ service: "github", action, resource_id: `${base}#${pullNumber}` }];
231
+ }
232
+ const issueNumber = input.issue_number ?? input.issueNumber;
233
+ if (issueNumber != null) {
234
+ return [{ service: "github", action, resource_id: `${base}#${issueNumber}` }];
235
+ }
236
+ const path = input.path;
237
+ if (path) {
238
+ return [{ service: "github", action, resource_id: `${base}:${path}` }];
239
+ }
240
+ // Just naming the repo (list_prs etc.) — record the repo itself.
241
+ return [{ service: "github", action, resource_id: base }];
242
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Identity-token helper for Cloud Run service-to-service auth.
3
+ *
4
+ * On Cloud Run / Cloud Functions / GCE / GKE the metadata server hands out
5
+ * a Google-signed ID token scoped to a single audience (typically the URL
6
+ * of the target service). Callers can attach this token in `Authorization:
7
+ * Bearer <id-token>` so the target's IAM check (`roles/run.invoker`)
8
+ * passes for the calling service account.
9
+ *
10
+ * Tokens are cached per audience until ~30s before expiry. The metadata
11
+ * server itself is rate-limited, so this matters under load.
12
+ */
13
+ /**
14
+ * Fetch an identity token for `audience` from the GCE/Cloud Run metadata
15
+ * server. Throws if not on a GCP environment with a metadata endpoint.
16
+ * Result is cached until 30s before its embedded `exp` claim.
17
+ */
18
+ export declare function fetchIdToken(audience: string): Promise<string>;
19
+ /**
20
+ * Convenience: build an `authHeaders` function suitable for `EngramOptions`.
21
+ *
22
+ * const engram = new Engram({
23
+ * apiKey: process.env.ENGRAM_API_KEY!,
24
+ * baseUrl: process.env.ENGRAM_BASE_URL!,
25
+ * apiKeyHeader: "x-api-key",
26
+ * authHeaders: cloudRunIdTokenAuth(process.env.ENGRAM_BASE_URL!),
27
+ * });
28
+ */
29
+ export declare function cloudRunIdTokenAuth(audience: string): () => Promise<Record<string, string>>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Identity-token helper for Cloud Run service-to-service auth.
3
+ *
4
+ * On Cloud Run / Cloud Functions / GCE / GKE the metadata server hands out
5
+ * a Google-signed ID token scoped to a single audience (typically the URL
6
+ * of the target service). Callers can attach this token in `Authorization:
7
+ * Bearer <id-token>` so the target's IAM check (`roles/run.invoker`)
8
+ * passes for the calling service account.
9
+ *
10
+ * Tokens are cached per audience until ~30s before expiry. The metadata
11
+ * server itself is rate-limited, so this matters under load.
12
+ */
13
+ const METADATA_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity";
14
+ const cache = new Map();
15
+ const SAFETY_WINDOW_MS = 30_000;
16
+ function decodeExp(token) {
17
+ const parts = token.split(".");
18
+ if (parts.length !== 3)
19
+ return null;
20
+ try {
21
+ const payload = JSON.parse(atob(parts[1]));
22
+ return typeof payload.exp === "number" ? payload.exp * 1000 : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /**
29
+ * Fetch an identity token for `audience` from the GCE/Cloud Run metadata
30
+ * server. Throws if not on a GCP environment with a metadata endpoint.
31
+ * Result is cached until 30s before its embedded `exp` claim.
32
+ */
33
+ export async function fetchIdToken(audience) {
34
+ const now = Date.now();
35
+ const cached = cache.get(audience);
36
+ if (cached && now < cached.expiresAt - SAFETY_WINDOW_MS)
37
+ return cached.token;
38
+ const url = `${METADATA_URL}?audience=${encodeURIComponent(audience)}`;
39
+ const res = await fetch(url, { headers: { "metadata-flavor": "Google" } });
40
+ if (!res.ok) {
41
+ throw new Error(`fetchIdToken ${res.status}: ${await res.text().catch(() => "")}`);
42
+ }
43
+ const token = (await res.text()).trim();
44
+ const exp = decodeExp(token);
45
+ cache.set(audience, { token, expiresAt: exp ?? now + 60 * 60 * 1000 });
46
+ return token;
47
+ }
48
+ /**
49
+ * Convenience: build an `authHeaders` function suitable for `EngramOptions`.
50
+ *
51
+ * const engram = new Engram({
52
+ * apiKey: process.env.ENGRAM_API_KEY!,
53
+ * baseUrl: process.env.ENGRAM_BASE_URL!,
54
+ * apiKeyHeader: "x-api-key",
55
+ * authHeaders: cloudRunIdTokenAuth(process.env.ENGRAM_BASE_URL!),
56
+ * });
57
+ */
58
+ export function cloudRunIdTokenAuth(audience) {
59
+ return async () => ({
60
+ authorization: `Bearer ${await fetchIdToken(audience)}`,
61
+ });
62
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Engram, EngramSession, type EngramOptions, type RecordStepInput, type SearchRequest, type SearchResponse, } from "./client";
2
2
  export { extractReferences, encodeResourceId, type RefCandidate, type ReferenceService, type ReferenceAction, } from "./extract";
3
3
  export { parseToolName, type ParsedToolName } from "./tool-name";
4
+ export { fetchIdToken, cloudRunIdTokenAuth } from "./id-token";
4
5
  export type { SessionInit, SessionAck, SessionEvent, StepEvent, ParticipantEvent, TitleEvent, EndEvent, EventBatch, } from "./types";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { Engram, EngramSession, } from "./client";
2
2
  export { extractReferences, encodeResourceId, } from "./extract";
3
3
  export { parseToolName } from "./tool-name";
4
+ export { fetchIdToken, cloudRunIdTokenAuth } from "./id-token";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexis-ai/engram-sdk",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Host SDK for engram. Records agent session steps and ships them to an engram server.",
5
5
  "keywords": ["engram", "agents", "claude", "anthropic", "sdk", "observability"],
6
6
  "homepage": "https://github.com/hexis-ltd/engram#readme",
@@ -40,7 +40,7 @@
40
40
  "type-check": "tsc --noEmit"
41
41
  },
42
42
  "dependencies": {
43
- "@hexis-ai/engram-core": "^0.1.2"
43
+ "@hexis-ai/engram-core": "^0.1.4"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"