@hexis-ai/engram-server 0.13.0 → 0.14.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.
@@ -6,11 +6,20 @@ export declare class InMemoryKeyStore implements KeyStore {
6
6
  private readonly workspaces;
7
7
  private readonly keys;
8
8
  private readonly byHash;
9
+ /** Mirror of engram_workspaces.org_id. Lookup-only — fakes / org-stores
10
+ * in tests read this to answer membership queries without re-tracking
11
+ * the binding themselves. */
12
+ private readonly orgIds;
9
13
  createWorkspace(input: {
10
14
  id?: string;
11
15
  name?: string;
12
16
  metadata?: Record<string, unknown>;
17
+ orgId?: string;
13
18
  }): Promise<Workspace>;
19
+ /** Org id stamped on a workspace at creation, or undefined if not org-scoped. */
20
+ getWorkspaceOrgId(workspaceId: string): string | undefined;
21
+ /** All (workspaceId, orgId) pairs. Read-only view for fakes. */
22
+ workspaceOrgBindings(): ReadonlyMap<string, string>;
14
23
  getWorkspace(id: string): Promise<Workspace | null>;
15
24
  listWorkspaces(): Promise<Workspace[]>;
16
25
  updateWorkspace(id: string, patch: {
@@ -6,6 +6,10 @@ export class InMemoryKeyStore {
6
6
  workspaces = new Map();
7
7
  keys = new Map();
8
8
  byHash = new Map();
9
+ /** Mirror of engram_workspaces.org_id. Lookup-only — fakes / org-stores
10
+ * in tests read this to answer membership queries without re-tracking
11
+ * the binding themselves. */
12
+ orgIds = new Map();
9
13
  async createWorkspace(input) {
10
14
  const id = resolveWorkspaceId(input);
11
15
  const existing = this.workspaces.get(id);
@@ -18,8 +22,18 @@ export class InMemoryKeyStore {
18
22
  createdAt: new Date().toISOString(),
19
23
  };
20
24
  this.workspaces.set(id, ws);
25
+ if (input.orgId !== undefined)
26
+ this.orgIds.set(id, input.orgId);
21
27
  return ws;
22
28
  }
29
+ /** Org id stamped on a workspace at creation, or undefined if not org-scoped. */
30
+ getWorkspaceOrgId(workspaceId) {
31
+ return this.orgIds.get(workspaceId);
32
+ }
33
+ /** All (workspaceId, orgId) pairs. Read-only view for fakes. */
34
+ workspaceOrgBindings() {
35
+ return this.orgIds;
36
+ }
23
37
  async getWorkspace(id) {
24
38
  return this.workspaces.get(id) ?? null;
25
39
  }
@@ -40,6 +54,7 @@ export class InMemoryKeyStore {
40
54
  }
41
55
  async deleteWorkspace(id) {
42
56
  this.workspaces.delete(id);
57
+ this.orgIds.delete(id);
43
58
  for (const [keyId, row] of this.keys) {
44
59
  if (row.workspaceId === id) {
45
60
  this.byHash.delete(row.keyHash);
@@ -42,6 +42,12 @@ export class InMemoryAdapter {
42
42
  ...(init.trigger_event_id != null
43
43
  ? { trigger_event_id: init.trigger_event_id }
44
44
  : {}),
45
+ ...(init.trigger_purpose != null
46
+ ? { trigger_purpose: init.trigger_purpose }
47
+ : {}),
48
+ ...(init.trigger_resume_hint != null
49
+ ? { trigger_resume_hint: init.trigger_resume_hint }
50
+ : {}),
45
51
  },
46
52
  events: new Map(),
47
53
  });
@@ -12,6 +12,7 @@ export declare class PostgresKeyStore implements KeyStore {
12
12
  id?: string;
13
13
  name?: string;
14
14
  metadata?: Record<string, unknown>;
15
+ orgId?: string;
15
16
  }): Promise<Workspace>;
16
17
  getWorkspace(id: string): Promise<Workspace | null>;
17
18
  listWorkspaces(): Promise<Workspace[]>;
@@ -16,8 +16,13 @@ export class PostgresKeyStore {
16
16
  async createWorkspace(input) {
17
17
  const id = resolveWorkspaceId(input);
18
18
  await this.sql `
19
- INSERT INTO engram_workspaces (id, name, metadata)
20
- VALUES (${id}, ${input.name ?? null}, ${(input.metadata ?? {})})
19
+ INSERT INTO engram_workspaces (id, name, metadata, org_id)
20
+ VALUES (
21
+ ${id},
22
+ ${input.name ?? null},
23
+ ${(input.metadata ?? {})},
24
+ ${input.orgId ?? null}
25
+ )
21
26
  ON CONFLICT (id) DO NOTHING
22
27
  `;
23
28
  const ws = await this.getWorkspace(id);
@@ -27,7 +27,6 @@ export declare class PostgresOrgStore implements OrgStore {
27
27
  email: string;
28
28
  } | null>;
29
29
  listOrgsForUser(userId: string): Promise<OrgMembershipRow[]>;
30
- setWorkspaceOrg(workspaceId: string, orgId: string): Promise<void>;
31
30
  listWorkspacesForOrg(orgId: string): Promise<{
32
31
  id: string;
33
32
  name: string | null;
@@ -89,9 +89,8 @@ export class PostgresOrgStore {
89
89
  return rows.map(toMember);
90
90
  }
91
91
  // ---------- workspace ↔ org link -----------------------------
92
- async setWorkspaceOrg(workspaceId, orgId) {
93
- await this.pool.query(`UPDATE engram_workspaces SET org_id = $1 WHERE id = $2`, [orgId, workspaceId]);
94
- }
92
+ // engram_workspaces.org_id is written by KeyStore.createWorkspace in
93
+ // the same INSERT as the workspace row. This store only reads.
95
94
  async listWorkspacesForOrg(orgId) {
96
95
  const { rows } = await this.pool.query(`SELECT id, name FROM engram_workspaces WHERE org_id = $1 ORDER BY created_at`, [orgId]);
97
96
  return rows;
@@ -31,7 +31,8 @@ export class PostgresAdapter {
31
31
  INSERT INTO engram_sessions (
32
32
  workspace_id, id, title, channel, participants, viewable_by,
33
33
  created_at, updated_at, status, summary, model,
34
- trigger_conversation_id, trigger_event_id
34
+ trigger_conversation_id, trigger_event_id,
35
+ trigger_purpose, trigger_resume_hint
35
36
  )
36
37
  VALUES (
37
38
  ${this.workspaceId},
@@ -46,7 +47,9 @@ export class PostgresAdapter {
46
47
  ${init.summary ?? null},
47
48
  ${init.model ?? null},
48
49
  ${init.trigger_conversation_id ?? null},
49
- ${init.trigger_event_id ?? null}
50
+ ${init.trigger_event_id ?? null},
51
+ ${init.trigger_purpose ?? null},
52
+ ${init.trigger_resume_hint ?? null}
50
53
  )
51
54
  ON CONFLICT (workspace_id, id) DO NOTHING
52
55
  `;
@@ -97,7 +100,8 @@ export class PostgresAdapter {
97
100
  const rows = await this.sql `
98
101
  SELECT id, title, channel, participants, viewable_by, created_at,
99
102
  updated_at, status, summary, model,
100
- trigger_conversation_id, trigger_event_id
103
+ trigger_conversation_id, trigger_event_id,
104
+ trigger_purpose, trigger_resume_hint
101
105
  FROM engram_sessions
102
106
  WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
103
107
  LIMIT 1
@@ -122,6 +126,8 @@ export class PostgresAdapter {
122
126
  const model = pickPatch(patch, "model");
123
127
  const tcId = pickPatch(patch, "trigger_conversation_id");
124
128
  const teId = pickPatch(patch, "trigger_event_id");
129
+ const tPurpose = pickPatch(patch, "trigger_purpose");
130
+ const tResume = pickPatch(patch, "trigger_resume_hint");
125
131
  const rows = await this.sql `
126
132
  UPDATE engram_sessions SET
127
133
  title = CASE WHEN ${title.provided} THEN ${title.value} ELSE title END,
@@ -133,6 +139,10 @@ export class PostgresAdapter {
133
139
  THEN ${tcId.value} ELSE trigger_conversation_id END,
134
140
  trigger_event_id = CASE WHEN ${teId.provided}
135
141
  THEN ${teId.value} ELSE trigger_event_id END,
142
+ trigger_purpose = CASE WHEN ${tPurpose.provided}
143
+ THEN ${tPurpose.value} ELSE trigger_purpose END,
144
+ trigger_resume_hint = CASE WHEN ${tResume.provided}
145
+ THEN ${tResume.value} ELSE trigger_resume_hint END,
136
146
  updated_at = now()
137
147
  WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
138
148
  RETURNING id
@@ -474,6 +484,12 @@ function toSessionRow(r) {
474
484
  ...(r.trigger_event_id !== null
475
485
  ? { trigger_event_id: r.trigger_event_id }
476
486
  : {}),
487
+ ...(r.trigger_purpose !== null
488
+ ? { trigger_purpose: r.trigger_purpose }
489
+ : {}),
490
+ ...(r.trigger_resume_hint !== null
491
+ ? { trigger_resume_hint: r.trigger_resume_hint }
492
+ : {}),
477
493
  };
478
494
  }
479
495
  function toAliasInfo(r) {
package/dist/admin.js CHANGED
@@ -158,9 +158,9 @@ export function createAdminRouter(opts) {
158
158
  return c.body(null, 204);
159
159
  }));
160
160
  // ----- org workspaces ---------------------------------------
161
- // Stands up a tenant in one round-trip. createWorkspaceUnderOrg
162
- // wraps createWorkspace + setWorkspaceOrg + issueKey and applies
163
- // the same id validation as the legacy /workspaces POST.
161
+ // Stands up a tenant. createWorkspaceUnderOrg writes the workspace
162
+ // + org binding in a single INSERT (atomic) and then issues the
163
+ // initial API key (separate insert; retry-safe via ON CONFLICT).
164
164
  app.post("/orgs/:id/workspaces", async (c) => runService(c, async () => {
165
165
  const orgId = c.req.param("id");
166
166
  await getOrgOrThrow(deps, orgId);
@@ -29,10 +29,18 @@ export interface KeyResolution {
29
29
  keyId: string;
30
30
  }
31
31
  export interface KeyStore {
32
+ /**
33
+ * Persist a workspace row. `orgId`, when supplied, lands in the same
34
+ * INSERT so the workspace and its org binding are committed atomically
35
+ * (no orphaned workspace if a follow-up `setWorkspaceOrg` would have
36
+ * failed). Legacy callers without an org still work — `org_id` stays
37
+ * NULL.
38
+ */
32
39
  createWorkspace(input: {
33
40
  id?: string;
34
41
  name?: string;
35
42
  metadata?: Record<string, unknown>;
43
+ orgId?: string;
36
44
  }): Promise<Workspace>;
37
45
  getWorkspace(id: string): Promise<Workspace | null>;
38
46
  listWorkspaces(): Promise<Workspace[]>;
@@ -0,0 +1,2 @@
1
+ export declare const name = "0008-trigger-metadata";
2
+ export declare const sql = "\n-- Hosts that open a side-conversation with a stated goal (monet's\n-- start_conversation tool) need to thread two free-text fields through\n-- engram for the agent's resume flow:\n--\n-- trigger_purpose what this side-conv exists to accomplish\n-- trigger_resume_hint the condition that should resume the parent\n--\n-- Both are opaque to engram \u2014 we store and serve them verbatim. Nullable\n-- because most sessions aren't side-conversations.\n\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS trigger_purpose TEXT;\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS trigger_resume_hint TEXT;\n";
@@ -0,0 +1,15 @@
1
+ export const name = "0008-trigger-metadata";
2
+ export const sql = `
3
+ -- Hosts that open a side-conversation with a stated goal (monet's
4
+ -- start_conversation tool) need to thread two free-text fields through
5
+ -- engram for the agent's resume flow:
6
+ --
7
+ -- trigger_purpose what this side-conv exists to accomplish
8
+ -- trigger_resume_hint the condition that should resume the parent
9
+ --
10
+ -- Both are opaque to engram — we store and serve them verbatim. Nullable
11
+ -- because most sessions aren't side-conversations.
12
+
13
+ ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS trigger_purpose TEXT;
14
+ ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS trigger_resume_hint TEXT;
15
+ `;
@@ -5,6 +5,7 @@ import * as m0004 from "./0004-schema-completion";
5
5
  import * as m0005 from "./0005-session-updated-at";
6
6
  import * as m0006 from "./0006-auth";
7
7
  import * as m0007 from "./0007-orgs";
8
+ import * as m0008 from "./0008-trigger-metadata";
8
9
  /**
9
10
  * Schema migrations, applied in array order. Add a new file under
10
11
  * `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
@@ -20,4 +21,5 @@ export const MIGRATIONS = [
20
21
  { name: m0005.name, sql: m0005.sql },
21
22
  { name: m0006.name, sql: m0006.sql },
22
23
  { name: m0007.name, sql: m0007.sql },
24
+ { name: m0008.name, sql: m0008.sql },
23
25
  ];
@@ -48,12 +48,6 @@ export interface OrgStore {
48
48
  } | null>;
49
49
  /** Orgs the given user is a member of. */
50
50
  listOrgsForUser(userId: string): Promise<OrgMembershipRow[]>;
51
- /**
52
- * Set the org an already-existing workspace belongs to. Called
53
- * by the admin endpoint that creates a workspace under an org
54
- * (key issuance and engram_workspaces.org_id are written together).
55
- */
56
- setWorkspaceOrg(workspaceId: string, orgId: string): Promise<void>;
57
51
  /** Workspaces in the given org. */
58
52
  listWorkspacesForOrg(orgId: string): Promise<{
59
53
  id: string;
package/dist/schemas.d.ts CHANGED
@@ -24,6 +24,8 @@ export declare const sessionInitSchema: z.ZodObject<{
24
24
  model: z.ZodOptional<z.ZodNullable<z.ZodString>>;
25
25
  trigger_conversation_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
26
26
  trigger_event_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
27
+ trigger_purpose: z.ZodOptional<z.ZodNullable<z.ZodString>>;
28
+ trigger_resume_hint: z.ZodOptional<z.ZodNullable<z.ZodString>>;
27
29
  }, z.core.$strip>;
28
30
  export declare const sessionUpdateSchema: z.ZodObject<{
29
31
  title: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -37,6 +39,8 @@ export declare const sessionUpdateSchema: z.ZodObject<{
37
39
  model: z.ZodOptional<z.ZodNullable<z.ZodString>>;
38
40
  trigger_conversation_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
39
41
  trigger_event_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
42
+ trigger_purpose: z.ZodOptional<z.ZodNullable<z.ZodString>>;
43
+ trigger_resume_hint: z.ZodOptional<z.ZodNullable<z.ZodString>>;
40
44
  }, z.core.$strip>;
41
45
  export declare const sessionEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
42
46
  type: z.ZodLiteral<"step">;
package/dist/schemas.js CHANGED
@@ -20,6 +20,8 @@ export const sessionInitSchema = z.object({
20
20
  model: z.string().nullable().optional(),
21
21
  trigger_conversation_id: z.string().nullable().optional(),
22
22
  trigger_event_id: z.string().nullable().optional(),
23
+ trigger_purpose: z.string().nullable().optional(),
24
+ trigger_resume_hint: z.string().nullable().optional(),
23
25
  });
24
26
  export const sessionUpdateSchema = z.object({
25
27
  title: z.string().nullable().optional(),
@@ -29,6 +31,8 @@ export const sessionUpdateSchema = z.object({
29
31
  model: z.string().nullable().optional(),
30
32
  trigger_conversation_id: z.string().nullable().optional(),
31
33
  trigger_event_id: z.string().nullable().optional(),
34
+ trigger_purpose: z.string().nullable().optional(),
35
+ trigger_resume_hint: z.string().nullable().optional(),
32
36
  });
33
37
  const stepEventSchema = z.object({
34
38
  type: z.literal("step"),
@@ -3,8 +3,9 @@
3
3
  * (`/admin/v1/*`) and the cookie-auth self-service surface (`/v1/orgs/*`).
4
4
  *
5
5
  * Auth and HTTP framing stay in the route modules. This file owns:
6
- * - the multi-step orchestrations (createWorkspace + setWorkspaceOrg
7
- * + issueKey; email→userId lookup + upsertMember)
6
+ * - the multi-step orchestrations (createWorkspaceUnderOrg writes
7
+ * the workspace + org binding atomically and then issues a key;
8
+ * email→userId lookup + upsertMember)
8
9
  * - the safety invariants (workspace id validation, last-owner
9
10
  * protection on member removal)
10
11
  * - the canonical error → status mapping (OrgServiceError.status)
@@ -70,12 +71,15 @@ export interface CreateWorkspaceResult {
70
71
  key?: IssuedKey;
71
72
  }
72
73
  /**
73
- * Stand up a new workspace under an org in one round-trip:
74
- * createWorkspace setWorkspaceOrg (optional) issueKey.
74
+ * Stand up a new workspace under an org. The workspace + org binding
75
+ * commit atomically because the KeyStore writes `org_id` in the same
76
+ * INSERT as the workspace row. Issuing the initial API key remains a
77
+ * follow-up call (idempotent — a retry of the workspace POST returns
78
+ * the existing row via ON CONFLICT DO NOTHING).
75
79
  *
76
80
  * Validates the workspace id shape up front so a bad id fails before
77
- * touching either store. Returns the workspace with `orgId` stamped on
78
- * so callers don't need a second lookup.
81
+ * touching the store. Returns the workspace with `orgId` stamped on so
82
+ * callers don't need a second lookup.
79
83
  */
80
84
  export declare function createWorkspaceUnderOrg(deps: OrgServiceDeps, orgId: string, body: CreateWorkspaceInput): Promise<CreateWorkspaceResult>;
81
85
  export declare function updateOrgWorkspace(deps: OrgServiceDeps, orgId: string, wsId: string, body: {
@@ -3,8 +3,9 @@
3
3
  * (`/admin/v1/*`) and the cookie-auth self-service surface (`/v1/orgs/*`).
4
4
  *
5
5
  * Auth and HTTP framing stay in the route modules. This file owns:
6
- * - the multi-step orchestrations (createWorkspace + setWorkspaceOrg
7
- * + issueKey; email→userId lookup + upsertMember)
6
+ * - the multi-step orchestrations (createWorkspaceUnderOrg writes
7
+ * the workspace + org binding atomically and then issues a key;
8
+ * email→userId lookup + upsertMember)
8
9
  * - the safety invariants (workspace id validation, last-owner
9
10
  * protection on member removal)
10
11
  * - the canonical error → status mapping (OrgServiceError.status)
@@ -92,12 +93,15 @@ export async function removeMember(deps, orgId, userId) {
92
93
  await deps.orgStore.removeMember(orgId, userId);
93
94
  }
94
95
  /**
95
- * Stand up a new workspace under an org in one round-trip:
96
- * createWorkspace setWorkspaceOrg (optional) issueKey.
96
+ * Stand up a new workspace under an org. The workspace + org binding
97
+ * commit atomically because the KeyStore writes `org_id` in the same
98
+ * INSERT as the workspace row. Issuing the initial API key remains a
99
+ * follow-up call (idempotent — a retry of the workspace POST returns
100
+ * the existing row via ON CONFLICT DO NOTHING).
97
101
  *
98
102
  * Validates the workspace id shape up front so a bad id fails before
99
- * touching either store. Returns the workspace with `orgId` stamped on
100
- * so callers don't need a second lookup.
103
+ * touching the store. Returns the workspace with `orgId` stamped on so
104
+ * callers don't need a second lookup.
101
105
  */
102
106
  export async function createWorkspaceUnderOrg(deps, orgId, body) {
103
107
  if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
@@ -108,8 +112,8 @@ export async function createWorkspaceUnderOrg(deps, orgId, body) {
108
112
  ...(body.id !== undefined ? { id: body.id } : {}),
109
113
  ...(body.name !== undefined ? { name: body.name } : {}),
110
114
  ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
115
+ orgId,
111
116
  });
112
- await deps.orgStore.setWorkspaceOrg(ws.id, orgId);
113
117
  if (body.issueKey === false) {
114
118
  return { workspace: { ...ws, orgId } };
115
119
  }
package/dist/storage.d.ts CHANGED
@@ -147,6 +147,8 @@ export interface SessionRow {
147
147
  model?: string;
148
148
  trigger_conversation_id?: string;
149
149
  trigger_event_id?: string;
150
+ trigger_purpose?: string;
151
+ trigger_resume_hint?: string;
150
152
  }
151
153
  export declare function foldEvents(row: SessionRow, events: SessionEvent[], now: Date): Session;
152
154
  /**
package/dist/storage.js CHANGED
@@ -35,6 +35,12 @@ export function foldEvents(row, events, now) {
35
35
  ...(row.trigger_event_id !== undefined
36
36
  ? { trigger_event_id: row.trigger_event_id }
37
37
  : {}),
38
+ ...(row.trigger_purpose !== undefined
39
+ ? { trigger_purpose: row.trigger_purpose }
40
+ : {}),
41
+ ...(row.trigger_resume_hint !== undefined
42
+ ? { trigger_resume_hint: row.trigger_resume_hint }
43
+ : {}),
38
44
  updated_at: row.updatedAt,
39
45
  };
40
46
  }
package/openapi.json CHANGED
@@ -145,6 +145,26 @@
145
145
  "type": "null"
146
146
  }
147
147
  ]
148
+ },
149
+ "trigger_purpose": {
150
+ "anyOf": [
151
+ {
152
+ "type": "string"
153
+ },
154
+ {
155
+ "type": "null"
156
+ }
157
+ ]
158
+ },
159
+ "trigger_resume_hint": {
160
+ "anyOf": [
161
+ {
162
+ "type": "string"
163
+ },
164
+ {
165
+ "type": "null"
166
+ }
167
+ ]
148
168
  }
149
169
  },
150
170
  "additionalProperties": false
@@ -219,6 +239,26 @@
219
239
  "type": "null"
220
240
  }
221
241
  ]
242
+ },
243
+ "trigger_purpose": {
244
+ "anyOf": [
245
+ {
246
+ "type": "string"
247
+ },
248
+ {
249
+ "type": "null"
250
+ }
251
+ ]
252
+ },
253
+ "trigger_resume_hint": {
254
+ "anyOf": [
255
+ {
256
+ "type": "string"
257
+ },
258
+ {
259
+ "type": "null"
260
+ }
261
+ ]
222
262
  }
223
263
  },
224
264
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexis-ai/engram-server",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Engram server: ingest agent session events, persist via a pluggable adapter, expose search.",
5
5
  "keywords": [
6
6
  "engram",
@@ -49,8 +49,8 @@
49
49
  "dev": "bun --hot src/dev.ts"
50
50
  },
51
51
  "dependencies": {
52
- "@hexis-ai/engram-core": "^0.2.0",
53
- "@hexis-ai/engram-sdk": "^0.14.0",
52
+ "@hexis-ai/engram-core": "^0.3.0",
53
+ "@hexis-ai/engram-sdk": "^0.15.0",
54
54
  "better-auth": "^1.6.11",
55
55
  "hono": "^4.6.0",
56
56
  "pg": "^8.13.0",