@hexis-ai/engram-sdk 0.1.2 → 0.1.5

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Admin SDK: provisions workspaces and API keys against an engram-server
3
+ * `/admin/v1/*` mount. Use from privileged contexts only — the admin token
4
+ * is platform-root and must never reach end-user code paths.
5
+ */
6
+ export interface AdminClientOptions {
7
+ baseUrl: string;
8
+ adminToken: string;
9
+ fetch?: typeof fetch;
10
+ }
11
+ export interface Workspace {
12
+ id: string;
13
+ name?: string;
14
+ metadata?: Record<string, unknown>;
15
+ createdAt: string;
16
+ }
17
+ export interface ApiKey {
18
+ id: string;
19
+ workspaceId: string;
20
+ prefix: string;
21
+ name?: string;
22
+ createdAt: string;
23
+ lastUsedAt?: string;
24
+ revokedAt?: string;
25
+ }
26
+ export interface IssuedKey extends ApiKey {
27
+ /** Plaintext key. Stored only by the caller; never retrievable again. */
28
+ raw: string;
29
+ }
30
+ export interface CreateWorkspaceInput {
31
+ id?: string;
32
+ name?: string;
33
+ metadata?: Record<string, unknown>;
34
+ /** Whether to also issue an initial API key. Default true. */
35
+ issueKey?: boolean;
36
+ /** Optional name applied to the initial API key. */
37
+ keyName?: string;
38
+ }
39
+ export interface CreateWorkspaceResult {
40
+ workspace: Workspace;
41
+ /** Present when `issueKey !== false`. */
42
+ key?: IssuedKey;
43
+ }
44
+ export declare class EngramAdmin {
45
+ private readonly baseUrl;
46
+ private readonly adminToken;
47
+ private readonly fetchImpl;
48
+ constructor(opts: AdminClientOptions);
49
+ createWorkspace(input?: CreateWorkspaceInput): Promise<CreateWorkspaceResult>;
50
+ listWorkspaces(): Promise<Workspace[]>;
51
+ getWorkspace(id: string): Promise<Workspace>;
52
+ deleteWorkspace(id: string): Promise<void>;
53
+ issueKey(workspaceId: string, opts?: {
54
+ name?: string;
55
+ }): Promise<IssuedKey>;
56
+ listKeys(workspaceId: string): Promise<ApiKey[]>;
57
+ revokeKey(workspaceId: string, keyId: string): Promise<void>;
58
+ private request;
59
+ }
60
+ export declare function createAdminClient(opts: AdminClientOptions): EngramAdmin;
package/dist/admin.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Admin SDK: provisions workspaces and API keys against an engram-server
3
+ * `/admin/v1/*` mount. Use from privileged contexts only — the admin token
4
+ * is platform-root and must never reach end-user code paths.
5
+ */
6
+ export class EngramAdmin {
7
+ baseUrl;
8
+ adminToken;
9
+ fetchImpl;
10
+ constructor(opts) {
11
+ if (!opts.baseUrl)
12
+ throw new Error("EngramAdmin: baseUrl is required");
13
+ if (!opts.adminToken)
14
+ throw new Error("EngramAdmin: adminToken is required");
15
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
16
+ this.adminToken = opts.adminToken;
17
+ this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
18
+ }
19
+ async createWorkspace(input = {}) {
20
+ return this.request("POST", "/admin/v1/workspaces", input);
21
+ }
22
+ async listWorkspaces() {
23
+ const r = await this.request("GET", "/admin/v1/workspaces");
24
+ return r.workspaces;
25
+ }
26
+ async getWorkspace(id) {
27
+ return this.request("GET", `/admin/v1/workspaces/${encodeURIComponent(id)}`);
28
+ }
29
+ async deleteWorkspace(id) {
30
+ await this.request("DELETE", `/admin/v1/workspaces/${encodeURIComponent(id)}`);
31
+ }
32
+ async issueKey(workspaceId, opts = {}) {
33
+ return this.request("POST", `/admin/v1/workspaces/${encodeURIComponent(workspaceId)}/keys`, opts);
34
+ }
35
+ async listKeys(workspaceId) {
36
+ const r = await this.request("GET", `/admin/v1/workspaces/${encodeURIComponent(workspaceId)}/keys`);
37
+ return r.keys;
38
+ }
39
+ async revokeKey(workspaceId, keyId) {
40
+ await this.request("DELETE", `/admin/v1/workspaces/${encodeURIComponent(workspaceId)}/keys/${encodeURIComponent(keyId)}`);
41
+ }
42
+ async request(method, path, body) {
43
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
44
+ method,
45
+ headers: {
46
+ "content-type": "application/json",
47
+ authorization: `Bearer ${this.adminToken}`,
48
+ },
49
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
50
+ });
51
+ if (!res.ok) {
52
+ const text = await res.text().catch(() => "");
53
+ throw new Error(`engram-admin ${method} ${path} ${res.status}: ${text}`);
54
+ }
55
+ if (res.status === 204)
56
+ return undefined;
57
+ return (await res.json());
58
+ }
59
+ }
60
+ export function createAdminClient(opts) {
61
+ return new EngramAdmin(opts);
62
+ }
package/dist/client.d.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  import type { ScoredSession, SearchOptions, Session, SessionStep } from "@hexis-ai/engram-core";
2
2
  import { type RefCandidate } from "./extract";
3
- import type { EventBatch, SessionInit } from "./types";
3
+ import type { EventBatch, PersonCreate, PersonInfo, PersonMap, PersonUpdate, SessionInit } from "./types";
4
+ /**
5
+ * Envelope returned by session endpoints. The persons map is deduped
6
+ * across whatever sessions the response carries so display info isn't
7
+ * repeated per row.
8
+ */
9
+ export interface SessionEnvelope {
10
+ session: Session;
11
+ persons: PersonMap;
12
+ }
13
+ export interface SessionListEnvelope {
14
+ sessions: Session[];
15
+ persons: PersonMap;
16
+ }
17
+ export interface SearchEnvelope {
18
+ results: ScoredSession[];
19
+ persons: PersonMap;
20
+ }
4
21
  export interface EngramOptions {
5
22
  apiKey: string;
6
23
  baseUrl: string;
@@ -16,6 +33,25 @@ export interface EngramOptions {
16
33
  batchSize?: number;
17
34
  /** Hook invoked on transport errors. Default: console.error. */
18
35
  onError?: (err: unknown) => void;
36
+ /**
37
+ * Header name to carry the api key. Defaults to "Authorization" with a
38
+ * `Bearer <key>` value for backward compatibility. Set to "x-api-key"
39
+ * when the host adds its own `Authorization` (e.g. a Cloud Run ID token
40
+ * for service-to-service IAM).
41
+ */
42
+ apiKeyHeader?: "authorization" | "x-api-key";
43
+ /**
44
+ * Async hook resolved per-request that returns headers to attach. Use
45
+ * for short-lived credentials like Cloud Run identity tokens.
46
+ */
47
+ authHeaders?: () => Promise<Record<string, string>> | Record<string, string>;
48
+ /**
49
+ * Max retry attempts for a failed event batch (transient transport errors).
50
+ * Default 4 (~30s total with exponential backoff). 0 disables retries.
51
+ */
52
+ maxRetries?: number;
53
+ /** Base backoff in ms; doubled each attempt. Default 500. */
54
+ retryBackoffMs?: number;
19
55
  }
20
56
  export interface RecordStepInput {
21
57
  tool: string;
@@ -44,9 +80,8 @@ export interface SearchRequest {
44
80
  };
45
81
  options?: SearchOptions;
46
82
  }
47
- export interface SearchResponse {
48
- results: ScoredSession[];
49
- }
83
+ /** @deprecated Use `SearchEnvelope` (carries `persons` map too). */
84
+ export type SearchResponse = SearchEnvelope;
50
85
  export declare class Engram {
51
86
  private readonly apiKey;
52
87
  private readonly baseUrl;
@@ -54,18 +89,51 @@ export declare class Engram {
54
89
  private readonly flushIntervalMs;
55
90
  private readonly batchSize;
56
91
  private readonly onError;
92
+ private readonly apiKeyHeader;
93
+ private readonly authHeaders?;
94
+ readonly maxRetries: number;
95
+ readonly retryBackoffMs: number;
57
96
  constructor(opts: EngramOptions);
97
+ /** Probe identity — returns the workspace the configured key resolves to. */
98
+ me(): Promise<{
99
+ workspaceId: string;
100
+ }>;
58
101
  /** Begin a new session. Returns a handle for buffering events. */
59
102
  startSession(init?: SessionInit): Promise<EngramSession>;
60
- /** Fetch a single Session by id. */
61
- getSession(id: string): Promise<Session>;
62
- /** List recent sessions (server defines defaults & limits). */
103
+ /** Fetch a single session by id, plus the persons map for its participants/viewers. */
104
+ getSession(id: string): Promise<SessionEnvelope>;
105
+ /** List recent sessions plus the deduped persons map across them. */
63
106
  listSessions(opts?: {
64
107
  limit?: number;
65
108
  channel?: string;
66
- }): Promise<Session[]>;
109
+ }): Promise<SessionListEnvelope>;
67
110
  /** Run a search. */
68
- search(req: SearchRequest): Promise<SearchResponse>;
111
+ search(req: SearchRequest): Promise<SearchEnvelope>;
112
+ /** Person operations. The host (e.g. monet) resolves platform identities
113
+ * to person ids before storing sessions; this namespace lets it own the
114
+ * underlying person records (display name + canonical id). */
115
+ readonly persons: {
116
+ /** Allocate a new person id and return the row. */
117
+ create: (input: PersonCreate) => Promise<PersonInfo>;
118
+ /** Upsert at a host-supplied id (backfill / fallback). */
119
+ upsert: (id: string, input: PersonCreate) => Promise<PersonInfo>;
120
+ /** Patch profile fields. Returns 404 if id is unknown. */
121
+ update: (id: string, patch: PersonUpdate) => Promise<PersonInfo>;
122
+ get: (id: string) => Promise<PersonInfo>;
123
+ /** Free-text search (substring across id + display_name). */
124
+ list: (opts?: {
125
+ limit?: number;
126
+ q?: string;
127
+ }) => Promise<{
128
+ persons: PersonInfo[];
129
+ }>;
130
+ /** Sessions where this person appears in participants (default) or viewable_by. */
131
+ sessions: (id: string, opts?: {
132
+ limit?: number;
133
+ channel?: string;
134
+ scope?: "participant" | "viewable";
135
+ }) => Promise<SessionListEnvelope>;
136
+ };
69
137
  /** Internal: ship an event batch to the server. */
70
138
  sendBatch(sessionId: string, batch: EventBatch): Promise<void>;
71
139
  /** Internal accessor used by EngramSession to honor SDK config. */
@@ -86,11 +154,18 @@ export declare class EngramSession {
86
154
  private ended;
87
155
  constructor(engram: Engram, id: string);
88
156
  recordStep(input: RecordStepInput): SessionStep;
89
- addParticipant(identityRef: string): void;
157
+ /** Append a participant to the running session. `personId` must already be
158
+ * resolved by the host (engram-server does not resolve platform identities). */
159
+ addParticipant(personId: string): void;
90
160
  setTitle(title: string): void;
91
161
  /** Mark the session ended and flush buffered events. */
92
162
  end(): Promise<void>;
93
- /** Force a flush regardless of batch size / timer. */
163
+ /**
164
+ * Force a flush regardless of batch size / timer. Transient failures
165
+ * are retried with exponential backoff up to `maxRetries`. If every
166
+ * attempt fails the events are returned to the head of the buffer so
167
+ * the next flush picks them up.
168
+ */
94
169
  flush(): Promise<void>;
95
170
  private enqueue;
96
171
  }
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,17 +22,25 @@ 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;
29
+ }
30
+ /** Probe identity — returns the workspace the configured key resolves to. */
31
+ async me() {
32
+ return this.request("GET", "/v1/me");
21
33
  }
22
34
  /** Begin a new session. Returns a handle for buffering events. */
23
35
  async startSession(init = {}) {
24
36
  const ack = await this.request("POST", "/v1/sessions", init);
25
37
  return new EngramSession(this, ack.id);
26
38
  }
27
- /** Fetch a single Session by id. */
39
+ /** Fetch a single session by id, plus the persons map for its participants/viewers. */
28
40
  async getSession(id) {
29
41
  return this.request("GET", `/v1/sessions/${encodeURIComponent(id)}`);
30
42
  }
31
- /** List recent sessions (server defines defaults & limits). */
43
+ /** List recent sessions plus the deduped persons map across them. */
32
44
  async listSessions(opts = {}) {
33
45
  const qs = new URLSearchParams();
34
46
  if (opts.limit !== undefined)
@@ -42,6 +54,41 @@ export class Engram {
42
54
  async search(req) {
43
55
  return this.request("POST", "/v1/search", req);
44
56
  }
57
+ // ---- Persons ----
58
+ /** Person operations. The host (e.g. monet) resolves platform identities
59
+ * to person ids before storing sessions; this namespace lets it own the
60
+ * underlying person records (display name + canonical id). */
61
+ persons = {
62
+ /** Allocate a new person id and return the row. */
63
+ create: (input) => this.request("POST", "/v1/persons", input),
64
+ /** Upsert at a host-supplied id (backfill / fallback). */
65
+ upsert: (id, input) => this.request("PUT", `/v1/persons/${encodeURIComponent(id)}`, input),
66
+ /** Patch profile fields. Returns 404 if id is unknown. */
67
+ update: (id, patch) => this.request("PATCH", `/v1/persons/${encodeURIComponent(id)}`, patch),
68
+ get: (id) => this.request("GET", `/v1/persons/${encodeURIComponent(id)}`),
69
+ /** Free-text search (substring across id + display_name). */
70
+ list: (opts = {}) => {
71
+ const qs = new URLSearchParams();
72
+ if (opts.limit !== undefined)
73
+ qs.set("limit", String(opts.limit));
74
+ if (opts.q)
75
+ qs.set("q", opts.q);
76
+ const tail = qs.toString();
77
+ return this.request("GET", `/v1/persons${tail ? `?${tail}` : ""}`);
78
+ },
79
+ /** Sessions where this person appears in participants (default) or viewable_by. */
80
+ sessions: (id, opts = {}) => {
81
+ const qs = new URLSearchParams();
82
+ if (opts.limit !== undefined)
83
+ qs.set("limit", String(opts.limit));
84
+ if (opts.channel)
85
+ qs.set("channel", opts.channel);
86
+ if (opts.scope)
87
+ qs.set("scope", opts.scope);
88
+ const tail = qs.toString();
89
+ return this.request("GET", `/v1/persons/${encodeURIComponent(id)}/sessions${tail ? `?${tail}` : ""}`);
90
+ },
91
+ };
45
92
  /** Internal: ship an event batch to the server. */
46
93
  async sendBatch(sessionId, batch) {
47
94
  if (batch.events.length === 0)
@@ -53,12 +100,18 @@ export class Engram {
53
100
  return { flushIntervalMs: this.flushIntervalMs, batchSize: this.batchSize, onError: this.onError };
54
101
  }
55
102
  async request(method, path, body) {
103
+ const headers = { "content-type": "application/json" };
104
+ if (this.apiKeyHeader === "authorization") {
105
+ headers["authorization"] = `Bearer ${this.apiKey}`;
106
+ }
107
+ else {
108
+ headers["x-api-key"] = this.apiKey;
109
+ }
110
+ if (this.authHeaders)
111
+ Object.assign(headers, await this.authHeaders());
56
112
  const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
57
113
  method,
58
- headers: {
59
- "content-type": "application/json",
60
- authorization: `Bearer ${this.apiKey}`,
61
- },
114
+ headers,
62
115
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
63
116
  });
64
117
  if (!res.ok) {
@@ -98,12 +151,14 @@ export class EngramSession {
98
151
  this.enqueue(ev);
99
152
  return { tool, resources };
100
153
  }
101
- addParticipant(identityRef) {
154
+ /** Append a participant to the running session. `personId` must already be
155
+ * resolved by the host (engram-server does not resolve platform identities). */
156
+ addParticipant(personId) {
102
157
  const ev = {
103
158
  type: "participant",
104
159
  seq: this.seq++,
105
160
  at: new Date().toISOString(),
106
- identityRef,
161
+ personId,
107
162
  };
108
163
  this.enqueue(ev);
109
164
  }
@@ -128,7 +183,12 @@ export class EngramSession {
128
183
  });
129
184
  await this.flush();
130
185
  }
131
- /** Force a flush regardless of batch size / timer. */
186
+ /**
187
+ * Force a flush regardless of batch size / timer. Transient failures
188
+ * are retried with exponential backoff up to `maxRetries`. If every
189
+ * attempt fails the events are returned to the head of the buffer so
190
+ * the next flush picks them up.
191
+ */
132
192
  async flush() {
133
193
  if (this.flushTimer) {
134
194
  clearTimeout(this.flushTimer);
@@ -139,14 +199,32 @@ export class EngramSession {
139
199
  if (this.buffer.length === 0)
140
200
  return;
141
201
  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);
202
+ const { maxRetries, retryBackoffMs, onError } = {
203
+ maxRetries: this.engram.maxRetries,
204
+ retryBackoffMs: this.engram.retryBackoffMs,
205
+ onError: this.engram.config.onError,
206
+ };
207
+ this.inFlight = (async () => {
208
+ let lastErr;
209
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
210
+ try {
211
+ await this.engram.sendBatch(this.id, { events });
212
+ return;
213
+ }
214
+ catch (e) {
215
+ lastErr = e;
216
+ onError(e);
217
+ if (attempt < maxRetries) {
218
+ const delay = retryBackoffMs * 2 ** attempt;
219
+ await new Promise((r) => setTimeout(r, delay));
220
+ }
221
+ }
222
+ }
223
+ // All retries exhausted — return events to the buffer head so a
224
+ // future flush (or session.end()) can take another swing.
146
225
  this.buffer.unshift(...events);
147
- throw e;
148
- })
149
- .finally(() => {
226
+ throw lastErr;
227
+ })().finally(() => {
150
228
  this.inFlight = null;
151
229
  });
152
230
  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,6 @@
1
- export { Engram, EngramSession, type EngramOptions, type RecordStepInput, type SearchRequest, type SearchResponse, } from "./client";
1
+ export { Engram, EngramSession, type EngramOptions, type RecordStepInput, type SearchRequest, type SearchResponse, type SearchEnvelope, type SessionEnvelope, type SessionListEnvelope, } 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 type { SessionInit, SessionAck, SessionEvent, StepEvent, ParticipantEvent, TitleEvent, EndEvent, EventBatch, } from "./types";
4
+ export { fetchIdToken, cloudRunIdTokenAuth } from "./id-token";
5
+ export { EngramAdmin, createAdminClient, type AdminClientOptions, type CreateWorkspaceInput, type CreateWorkspaceResult, type Workspace as AdminWorkspace, type ApiKey as AdminApiKey, type IssuedKey as AdminIssuedKey, } from "./admin";
6
+ export type { SessionInit, SessionAck, SessionEvent, StepEvent, ParticipantEvent, TitleEvent, EndEvent, EventBatch, PersonInfo, PersonCreate, PersonUpdate, PersonMap, } from "./types";
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
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";
5
+ export { EngramAdmin, createAdminClient, } from "./admin";
package/dist/types.d.ts CHANGED
@@ -13,7 +13,18 @@ export interface SessionInit {
13
13
  * (e.g. `chat_ui`, `slack_dm`, `cron:dailyDigest`).
14
14
  */
15
15
  channel?: string;
16
+ /**
17
+ * Person ids that took part. The host (e.g. monet) is responsible for
18
+ * resolving platform identities (slack:U..., email:...) to person ids
19
+ * before calling startSession.
20
+ */
16
21
  participants?: string[];
22
+ /**
23
+ * Person ids that may view this session. Defaults to participants when
24
+ * omitted. engram-server unions this with participants to guarantee
25
+ * `participants ⊆ viewable_by`.
26
+ */
27
+ viewable_by?: string[];
17
28
  }
18
29
  export interface SessionAck {
19
30
  id: string;
@@ -29,7 +40,8 @@ export interface ParticipantEvent {
29
40
  type: "participant";
30
41
  seq: number;
31
42
  at: string;
32
- identityRef: string;
43
+ /** Person id (resolved by the host). */
44
+ personId: string;
33
45
  }
34
46
  export interface TitleEvent {
35
47
  type: "title";
@@ -46,3 +58,27 @@ export type SessionEvent = StepEvent | ParticipantEvent | TitleEvent | EndEvent;
46
58
  export interface EventBatch {
47
59
  events: SessionEvent[];
48
60
  }
61
+ /**
62
+ * Minimal representation engram-server returns for any person. Hosts cache
63
+ * `display_name` for UI rendering; richer profile attributes (role, team,
64
+ * etc.) are intentionally NOT here — those belong to HR / source-of-truth
65
+ * systems the host integrates with.
66
+ */
67
+ export interface PersonInfo {
68
+ id: string;
69
+ display_name: string | null;
70
+ created_at: string;
71
+ updated_at: string;
72
+ }
73
+ export interface PersonCreate {
74
+ display_name?: string;
75
+ }
76
+ export interface PersonUpdate {
77
+ display_name?: string | null;
78
+ }
79
+ /**
80
+ * Map of person_id → PersonInfo, deduped across whatever `Session`s the
81
+ * response contains. Returned at the envelope level so list responses
82
+ * don't repeat the same person info per row.
83
+ */
84
+ export type PersonMap = Record<string, PersonInfo>;
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.5",
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.5"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"