@hexis-ai/engram-server 0.8.0 → 0.10.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.
@@ -29,6 +29,7 @@ export declare class InMemoryAdapter implements StorageAdapter {
29
29
  listSessions(opts: {
30
30
  limit: number;
31
31
  channel?: string;
32
+ status?: "active" | "idle" | "completed";
32
33
  }): Promise<Session[]>;
33
34
  sessionsForPerson(personId: string, opts: {
34
35
  limit: number;
@@ -48,6 +49,7 @@ export declare class InMemoryAdapter implements StorageAdapter {
48
49
  name: string;
49
50
  } & AliasUpsert): Promise<AliasInfo | null>;
50
51
  listAliases(personId: string): Promise<AliasInfo[]>;
52
+ findAliasesByName(name: string): Promise<AliasInfo[]>;
51
53
  upsertIdentity(ref: string, input: IdentityUpsert): Promise<IdentityInfo | null>;
52
54
  getIdentityByRef(ref: string): Promise<IdentityInfo | null>;
53
55
  listIdentitiesByPerson(personId: string): Promise<IdentityInfo[]>;
@@ -37,6 +37,7 @@ export class InMemoryAdapter {
37
37
  participants,
38
38
  viewable_by,
39
39
  createdAt: init.createdAt,
40
+ updatedAt: init.createdAt,
40
41
  // status defaults to 'active' to match the Postgres column default.
41
42
  status: init.status ?? "active",
42
43
  ...(init.summary != null ? { summary: init.summary } : {}),
@@ -69,6 +70,13 @@ export class InMemoryAdapter {
69
70
  };
70
71
  }
71
72
  }
73
+ if (events.length > 0) {
74
+ const latestAt = events[events.length - 1].at;
75
+ // Bump updated_at to the latest event's `at` (monotonic).
76
+ if (latestAt > s.row.updatedAt) {
77
+ s.row = { ...s.row, updatedAt: latestAt };
78
+ }
79
+ }
72
80
  }
73
81
  async getSession(sessionId) {
74
82
  const s = this.sessions.get(sessionId);
@@ -123,6 +131,7 @@ export class InMemoryAdapter {
123
131
  else
124
132
  next.trigger_event_id = patch.trigger_event_id;
125
133
  }
134
+ next.updatedAt = new Date().toISOString();
126
135
  s.row = next;
127
136
  return foldEvents(s.row, [...s.events.values()], new Date());
128
137
  }
@@ -138,12 +147,14 @@ export class InMemoryAdapter {
138
147
  for (const stored of this.sessions.values()) {
139
148
  if (opts.channel && stored.row.channel !== opts.channel)
140
149
  continue;
150
+ if (opts.status && stored.row.status !== opts.status)
151
+ continue;
141
152
  all.push({
142
153
  s: foldEvents(stored.row, [...stored.events.values()], now),
143
- createdAt: stored.row.createdAt,
154
+ updatedAt: stored.row.updatedAt,
144
155
  });
145
156
  }
146
- all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
157
+ all.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
147
158
  return all.slice(0, opts.limit).map((x) => x.s);
148
159
  }
149
160
  async sessionsForPerson(personId, opts) {
@@ -172,15 +183,17 @@ export class InMemoryAdapter {
172
183
  async upsertPerson(id, input) {
173
184
  const now = new Date().toISOString();
174
185
  const existing = this.persons.get(id);
186
+ // `PersonCreate` has no notion of "clear" — that's `updatePerson`'s
187
+ // job. Missing / null in input means "keep what's there", matching
188
+ // the Postgres adapter's COALESCE-on-conflict.
175
189
  const next = {
176
190
  id,
177
- // `PersonCreate` has no notion of "clear the name" — that is
178
- // `updatePerson`'s job. A missing value (and, defensively, an
179
- // explicit null that bypasses the schema) means "keep what's
180
- // there", matching the Postgres adapter's `COALESCE(EXCLUDED.…)`.
181
191
  display_name: input.display_name != null
182
192
  ? input.display_name
183
193
  : existing?.display_name ?? null,
194
+ role: input.role != null ? input.role : existing?.role ?? null,
195
+ team: input.team != null ? input.team : existing?.team ?? null,
196
+ source: input.source ?? existing?.source ?? "auto",
184
197
  created_at: existing?.created_at ?? now,
185
198
  updated_at: now,
186
199
  };
@@ -192,9 +205,13 @@ export class InMemoryAdapter {
192
205
  if (!existing)
193
206
  return null;
194
207
  const now = new Date().toISOString();
208
+ // undefined = no-op, null = clear (per the SDK contract).
195
209
  const next = {
196
210
  ...existing,
197
211
  display_name: patch.display_name !== undefined ? patch.display_name : existing.display_name,
212
+ role: patch.role !== undefined ? patch.role : existing.role ?? null,
213
+ team: patch.team !== undefined ? patch.team : existing.team ?? null,
214
+ source: patch.source !== undefined ? patch.source : existing.source ?? "auto",
198
215
  updated_at: now,
199
216
  };
200
217
  this.persons.set(id, next);
@@ -215,9 +232,28 @@ export class InMemoryAdapter {
215
232
  async listPersons(opts) {
216
233
  const q = opts.q?.trim().toLowerCase() ?? "";
217
234
  const all = [...this.persons.values()];
235
+ // Search spans person fields + active identities (matching the
236
+ // Postgres impl). Slow O(n*m) here is fine — in-memory is for
237
+ // tests / dev / small single-node deploys.
218
238
  const matched = q
219
- ? all.filter((p) => p.id.toLowerCase().includes(q) ||
220
- (p.display_name?.toLowerCase().includes(q) ?? false))
239
+ ? all.filter((p) => {
240
+ if (p.id.toLowerCase().includes(q))
241
+ return true;
242
+ if (p.display_name?.toLowerCase().includes(q))
243
+ return true;
244
+ if (p.role?.toLowerCase().includes(q))
245
+ return true;
246
+ if (p.team?.toLowerCase().includes(q))
247
+ return true;
248
+ for (const i of this.identities.values()) {
249
+ if (i.person_id === p.id &&
250
+ i.unlinked_at == null &&
251
+ i.display_name?.toLowerCase().includes(q)) {
252
+ return true;
253
+ }
254
+ }
255
+ return false;
256
+ })
221
257
  : all;
222
258
  matched.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
223
259
  return matched.slice(0, opts.limit);
@@ -251,12 +287,22 @@ export class InMemoryAdapter {
251
287
  matches.sort((a, b) => b.last_used.localeCompare(a.last_used));
252
288
  return matches;
253
289
  }
290
+ async findAliasesByName(name) {
291
+ const lower = name.toLowerCase();
292
+ const matches = [...this.aliases.values()].filter((a) => a.name.toLowerCase() === lower);
293
+ matches.sort((a, b) => b.last_used.localeCompare(a.last_used));
294
+ return matches;
295
+ }
254
296
  // --- Identities ---------------------------------------------------
255
297
  async upsertIdentity(ref, input) {
256
298
  if (!this.persons.has(input.person_id))
257
299
  return null;
258
300
  const now = new Date().toISOString();
259
301
  const existing = this.identities.get(ref);
302
+ // COALESCE-on-conflict for the soft-overlap fields; ref-level
303
+ // mapping (person_id, service, external_id, linked_at) always
304
+ // takes the latest input. unlinked_at uses undefined=no-change
305
+ // semantics so callers can re-link without explicitly clearing.
260
306
  const next = {
261
307
  ref,
262
308
  person_id: input.person_id,
@@ -266,11 +312,12 @@ export class InMemoryAdapter {
266
312
  ? input.display_name
267
313
  : existing?.display_name ?? null,
268
314
  source: input.source !== undefined ? input.source : existing?.source ?? null,
269
- is_primary: input.is_primary !== undefined
270
- ? input.is_primary
271
- : existing?.is_primary ?? null,
315
+ is_primary: input.is_primary ?? existing?.is_primary ?? false,
272
316
  picture: input.picture !== undefined ? input.picture : existing?.picture ?? null,
273
317
  linked_at: input.linked_at,
318
+ unlinked_at: input.unlinked_at !== undefined
319
+ ? input.unlinked_at
320
+ : existing?.unlinked_at ?? null,
274
321
  created_at: existing?.created_at ?? now,
275
322
  updated_at: now,
276
323
  };
@@ -42,6 +42,7 @@ export declare class PostgresAdapter implements StorageAdapter {
42
42
  listSessions(opts: {
43
43
  limit: number;
44
44
  channel?: string;
45
+ status?: "active" | "idle" | "completed";
45
46
  }): Promise<Session[]>;
46
47
  sessionsForPerson(personId: string, opts: {
47
48
  limit: number;
@@ -61,6 +62,7 @@ export declare class PostgresAdapter implements StorageAdapter {
61
62
  name: string;
62
63
  } & AliasUpsert): Promise<AliasInfo | null>;
63
64
  listAliases(personId: string): Promise<AliasInfo[]>;
65
+ findAliasesByName(name: string): Promise<AliasInfo[]>;
64
66
  upsertIdentity(ref: string, input: IdentityUpsert): Promise<IdentityInfo | null>;
65
67
  getIdentityByRef(ref: string): Promise<IdentityInfo | null>;
66
68
  listIdentitiesByPerson(personId: string): Promise<IdentityInfo[]>;
@@ -43,7 +43,7 @@ export class PostgresAdapter {
43
43
  await this.sql `
44
44
  INSERT INTO engram_sessions (
45
45
  workspace_id, id, title, channel, participants, viewable_by,
46
- created_at, status, summary, model,
46
+ created_at, updated_at, status, summary, model,
47
47
  trigger_conversation_id, trigger_event_id
48
48
  )
49
49
  VALUES (
@@ -54,6 +54,7 @@ export class PostgresAdapter {
54
54
  ${participants},
55
55
  ${viewableBy},
56
56
  ${init.createdAt},
57
+ ${init.createdAt},
57
58
  ${init.status ?? "active"},
58
59
  ${init.summary ?? null},
59
60
  ${init.model ?? null},
@@ -95,11 +96,20 @@ export class PostgresAdapter {
95
96
  `;
96
97
  }
97
98
  }
99
+ // Bump updated_at once per batch so list-by-recent-activity stays
100
+ // accurate. Using the latest event's `at` (not now()) keeps the
101
+ // semantics deterministic for back-dated batches.
102
+ const latest = events[events.length - 1];
103
+ await this.sql `
104
+ UPDATE engram_sessions
105
+ SET updated_at = GREATEST(updated_at, ${latest.at}::timestamptz)
106
+ WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
107
+ `;
98
108
  }
99
109
  async getSession(sessionId) {
100
110
  const rows = await this.sql `
101
111
  SELECT id, title, channel, participants, viewable_by, created_at,
102
- status, summary, model,
112
+ updated_at, status, summary, model,
103
113
  trigger_conversation_id, trigger_event_id
104
114
  FROM engram_sessions
105
115
  WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
@@ -137,7 +147,8 @@ export class PostgresAdapter {
137
147
  ELSE trigger_conversation_id END,
138
148
  trigger_event_id = CASE WHEN ${teIdProvided}
139
149
  THEN ${patch.trigger_event_id ?? null}
140
- ELSE trigger_event_id END
150
+ ELSE trigger_event_id END,
151
+ updated_at = now()
141
152
  WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
142
153
  RETURNING id
143
154
  `;
@@ -162,11 +173,13 @@ export class PostgresAdapter {
162
173
  }
163
174
  async listSessions(opts) {
164
175
  const channelFilter = opts.channel ?? null;
176
+ const statusFilter = opts.status ?? null;
165
177
  const rows = await this.sql `
166
178
  SELECT id FROM engram_sessions
167
179
  WHERE workspace_id = ${this.workspaceId}
168
180
  AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
169
- ORDER BY created_at DESC
181
+ AND (${statusFilter}::text IS NULL OR status = ${statusFilter}::text)
182
+ ORDER BY updated_at DESC
170
183
  LIMIT ${opts.limit}
171
184
  `;
172
185
  const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
@@ -204,24 +217,41 @@ export class PostgresAdapter {
204
217
  }
205
218
  async upsertPerson(id, input) {
206
219
  const rows = await this.sql `
207
- INSERT INTO engram_persons (workspace_id, id, display_name)
208
- VALUES (${this.workspaceId}, ${id}, ${input.display_name ?? null})
220
+ INSERT INTO engram_persons (workspace_id, id, display_name, role, team, source)
221
+ VALUES (
222
+ ${this.workspaceId}, ${id},
223
+ ${input.display_name ?? null},
224
+ ${input.role ?? null},
225
+ ${input.team ?? null},
226
+ ${input.source ?? "auto"}
227
+ )
209
228
  ON CONFLICT (workspace_id, id) DO UPDATE SET
229
+ -- COALESCE-on-conflict so partial-info upserts don't clobber
230
+ -- richer profile data set earlier.
210
231
  display_name = COALESCE(EXCLUDED.display_name, engram_persons.display_name),
232
+ role = COALESCE(EXCLUDED.role, engram_persons.role),
233
+ team = COALESCE(EXCLUDED.team, engram_persons.team),
234
+ source = COALESCE(EXCLUDED.source, engram_persons.source),
211
235
  updated_at = now()
212
- RETURNING id, display_name, created_at, updated_at
236
+ RETURNING id, display_name, role, team, source, created_at, updated_at
213
237
  `;
214
238
  return toPersonInfo(rows[0]);
215
239
  }
216
240
  async updatePerson(id, patch) {
217
241
  // Treat `null` as an explicit clear; `undefined` as no-op.
218
242
  const nameProvided = patch.display_name !== undefined;
243
+ const roleProvided = patch.role !== undefined;
244
+ const teamProvided = patch.team !== undefined;
245
+ const sourceProvided = patch.source !== undefined;
219
246
  const rows = await this.sql `
220
- UPDATE engram_persons
221
- SET display_name = CASE WHEN ${nameProvided} THEN ${patch.display_name ?? null} ELSE display_name END,
222
- updated_at = now()
247
+ UPDATE engram_persons SET
248
+ display_name = CASE WHEN ${nameProvided} THEN ${patch.display_name ?? null} ELSE display_name END,
249
+ role = CASE WHEN ${roleProvided} THEN ${patch.role ?? null} ELSE role END,
250
+ team = CASE WHEN ${teamProvided} THEN ${patch.team ?? null} ELSE team END,
251
+ source = CASE WHEN ${sourceProvided} THEN ${patch.source ?? "auto"} ELSE source END,
252
+ updated_at = now()
223
253
  WHERE workspace_id = ${this.workspaceId} AND id = ${id}
224
- RETURNING id, display_name, created_at, updated_at
254
+ RETURNING id, display_name, role, team, source, created_at, updated_at
225
255
  `;
226
256
  if (rows.length === 0)
227
257
  return null;
@@ -229,7 +259,7 @@ export class PostgresAdapter {
229
259
  }
230
260
  async getPerson(id) {
231
261
  const rows = await this.sql `
232
- SELECT id, display_name, created_at, updated_at
262
+ SELECT id, display_name, role, team, source, created_at, updated_at
233
263
  FROM engram_persons
234
264
  WHERE workspace_id = ${this.workspaceId} AND id = ${id}
235
265
  LIMIT 1
@@ -242,7 +272,7 @@ export class PostgresAdapter {
242
272
  if (ids.length === 0)
243
273
  return [];
244
274
  const rows = await this.sql `
245
- SELECT id, display_name, created_at, updated_at
275
+ SELECT id, display_name, role, team, source, created_at, updated_at
246
276
  FROM engram_persons
247
277
  WHERE workspace_id = ${this.workspaceId}
248
278
  AND id = ANY(${ids}::text[])
@@ -253,18 +283,32 @@ export class PostgresAdapter {
253
283
  const q = opts.q?.trim() ?? "";
254
284
  if (q) {
255
285
  const pattern = `%${q.toLowerCase()}%`;
286
+ // Free-text search spans the person row + active identities so
287
+ // a query like "design" finds people by role *or* by external
288
+ // identity display_name (e.g. their Slack profile name).
256
289
  const rows = await this.sql `
257
- SELECT id, display_name, created_at, updated_at
258
- FROM engram_persons
259
- WHERE workspace_id = ${this.workspaceId}
260
- AND (lower(id) LIKE ${pattern} OR lower(coalesce(display_name, '')) LIKE ${pattern})
261
- ORDER BY updated_at DESC
290
+ SELECT DISTINCT p.id, p.display_name, p.role, p.team, p.source,
291
+ p.created_at, p.updated_at
292
+ FROM engram_persons p
293
+ LEFT JOIN engram_identities i
294
+ ON i.workspace_id = p.workspace_id
295
+ AND i.person_id = p.id
296
+ AND i.unlinked_at IS NULL
297
+ WHERE p.workspace_id = ${this.workspaceId}
298
+ AND (
299
+ lower(p.id) LIKE ${pattern}
300
+ OR lower(coalesce(p.display_name, '')) LIKE ${pattern}
301
+ OR lower(coalesce(p.role, '')) LIKE ${pattern}
302
+ OR lower(coalesce(p.team, '')) LIKE ${pattern}
303
+ OR lower(coalesce(i.display_name, '')) LIKE ${pattern}
304
+ )
305
+ ORDER BY p.updated_at DESC
262
306
  LIMIT ${opts.limit}
263
307
  `;
264
308
  return rows.map(toPersonInfo);
265
309
  }
266
310
  const rows = await this.sql `
267
- SELECT id, display_name, created_at, updated_at
311
+ SELECT id, display_name, role, team, source, created_at, updated_at
268
312
  FROM engram_persons
269
313
  WHERE workspace_id = ${this.workspaceId}
270
314
  ORDER BY updated_at DESC
@@ -323,6 +367,19 @@ export class PostgresAdapter {
323
367
  `;
324
368
  return rows.map(toAliasInfo);
325
369
  }
370
+ async findAliasesByName(name) {
371
+ // The `name_lower` column is a STORED generated lower(name) backed
372
+ // by `idx_engram_aliases_name_lower (workspace_id, name_lower)`, so
373
+ // this lookup is index-only regardless of casing in the input.
374
+ const rows = await this.sql `
375
+ SELECT person_id, name, caller, usage_count, last_used, created_at, updated_at
376
+ FROM engram_aliases
377
+ WHERE workspace_id = ${this.workspaceId}
378
+ AND name_lower = ${name.toLowerCase()}
379
+ ORDER BY last_used DESC
380
+ `;
381
+ return rows.map(toAliasInfo);
382
+ }
326
383
  // --- Identities ---------------------------------------------------
327
384
  async upsertIdentity(ref, input) {
328
385
  // Pre-check rather than rely on the FK so unknown persons return
@@ -334,17 +391,21 @@ export class PostgresAdapter {
334
391
  `;
335
392
  if (personExists.length === 0)
336
393
  return null;
394
+ // unlinked_at semantics: undefined = leave alone, null = clear,
395
+ // value = set. matches the patch contract for the other fields.
396
+ const unlinkedProvided = input.unlinked_at !== undefined;
337
397
  const rows = await this.sql `
338
398
  INSERT INTO engram_identities (
339
399
  workspace_id, ref, person_id, service, external_id,
340
- display_name, source, is_primary, picture, linked_at
400
+ display_name, source, is_primary, picture, linked_at, unlinked_at
341
401
  )
342
402
  VALUES (
343
403
  ${this.workspaceId}, ${ref}, ${input.person_id},
344
404
  ${input.service}, ${input.external_id},
345
405
  ${input.display_name ?? null}, ${input.source ?? null},
346
- ${input.is_primary ?? null}, ${input.picture ?? null},
347
- ${input.linked_at}
406
+ ${input.is_primary ?? false}, ${input.picture ?? null},
407
+ ${input.linked_at},
408
+ ${input.unlinked_at ?? null}
348
409
  )
349
410
  ON CONFLICT (workspace_id, ref) DO UPDATE SET
350
411
  person_id = EXCLUDED.person_id,
@@ -352,19 +413,24 @@ export class PostgresAdapter {
352
413
  external_id = EXCLUDED.external_id,
353
414
  display_name = COALESCE(EXCLUDED.display_name, engram_identities.display_name),
354
415
  source = COALESCE(EXCLUDED.source, engram_identities.source),
355
- is_primary = COALESCE(EXCLUDED.is_primary, engram_identities.is_primary),
416
+ is_primary = EXCLUDED.is_primary,
356
417
  picture = COALESCE(EXCLUDED.picture, engram_identities.picture),
357
418
  linked_at = EXCLUDED.linked_at,
419
+ unlinked_at = CASE WHEN ${unlinkedProvided}
420
+ THEN ${input.unlinked_at ?? null}
421
+ ELSE engram_identities.unlinked_at END,
358
422
  updated_at = now()
359
423
  RETURNING ref, person_id, service, external_id, display_name, source,
360
- is_primary, picture, linked_at, created_at, updated_at
424
+ is_primary, picture, linked_at, unlinked_at,
425
+ created_at, updated_at
361
426
  `;
362
427
  return toIdentityInfo(rows[0]);
363
428
  }
364
429
  async getIdentityByRef(ref) {
365
430
  const rows = await this.sql `
366
431
  SELECT ref, person_id, service, external_id, display_name, source,
367
- is_primary, picture, linked_at, created_at, updated_at
432
+ is_primary, picture, linked_at, unlinked_at,
433
+ created_at, updated_at
368
434
  FROM engram_identities
369
435
  WHERE workspace_id = ${this.workspaceId} AND ref = ${ref}
370
436
  LIMIT 1
@@ -376,7 +442,8 @@ export class PostgresAdapter {
376
442
  async listIdentitiesByPerson(personId) {
377
443
  const rows = await this.sql `
378
444
  SELECT ref, person_id, service, external_id, display_name, source,
379
- is_primary, picture, linked_at, created_at, updated_at
445
+ is_primary, picture, linked_at, unlinked_at,
446
+ created_at, updated_at
380
447
  FROM engram_identities
381
448
  WHERE workspace_id = ${this.workspaceId} AND person_id = ${personId}
382
449
  ORDER BY linked_at DESC
@@ -389,20 +456,23 @@ function toPersonInfo(r) {
389
456
  return {
390
457
  id: r.id,
391
458
  display_name: r.display_name,
459
+ role: r.role,
460
+ team: r.team,
461
+ source: r.source,
392
462
  created_at: toIso(r.created_at),
393
463
  updated_at: toIso(r.updated_at),
394
464
  };
395
465
  }
396
466
  function toSessionRow(r) {
467
+ const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
397
468
  return {
398
469
  id: r.id,
399
470
  ...(r.title ? { title: r.title } : {}),
400
471
  ...(r.channel ? { channel: r.channel } : {}),
401
472
  participants: r.participants,
402
473
  viewable_by: r.viewable_by,
403
- createdAt: typeof r.created_at === "string"
404
- ? r.created_at
405
- : r.created_at.toISOString(),
474
+ createdAt: toIso(r.created_at),
475
+ updatedAt: toIso(r.updated_at),
406
476
  ...(r.status === "active" || r.status === "idle" || r.status === "completed"
407
477
  ? { status: r.status }
408
478
  : {}),
@@ -435,9 +505,7 @@ function toAliasInfo(r) {
435
505
  }
436
506
  function toIdentityInfo(r) {
437
507
  const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
438
- const linkedAt = typeof r.linked_at === "string"
439
- ? r.linked_at.slice(0, 10)
440
- : r.linked_at.toISOString().slice(0, 10);
508
+ // linked_at is now TIMESTAMPTZ (post-migration 0004). Emit full ISO.
441
509
  return {
442
510
  ref: r.ref,
443
511
  person_id: r.person_id,
@@ -447,7 +515,8 @@ function toIdentityInfo(r) {
447
515
  source: r.source,
448
516
  is_primary: r.is_primary,
449
517
  picture: r.picture,
450
- linked_at: linkedAt,
518
+ linked_at: toIso(r.linked_at),
519
+ unlinked_at: r.unlinked_at === null ? null : toIso(r.unlinked_at),
451
520
  created_at: toIso(r.created_at),
452
521
  updated_at: toIso(r.updated_at),
453
522
  };
@@ -0,0 +1,2 @@
1
+ export declare const name = "0005-session-updated-at";
2
+ export declare const sql = "\n-- Session-level updated_at, so listSessions can sort by \"most recent\n-- activity\" (matching monet's conversations.updated_at semantics) and\n-- callers can filter \"active in the last hour\" without re-folding the\n-- event log. Maintained by appendEvents + updateSession at write time.\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;\nUPDATE engram_sessions SET updated_at = created_at WHERE updated_at IS NULL;\nALTER TABLE engram_sessions ALTER COLUMN updated_at SET DEFAULT NOW();\nALTER TABLE engram_sessions ALTER COLUMN updated_at SET NOT NULL;\n\n-- Index supporting the most common list-by-recent-activity query and\n-- its status-filtered variant (status filter is cheap once the date\n-- range scan is narrow).\nCREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_updated\n ON engram_sessions (workspace_id, updated_at DESC);\n";
@@ -0,0 +1,17 @@
1
+ export const name = "0005-session-updated-at";
2
+ export const sql = `
3
+ -- Session-level updated_at, so listSessions can sort by "most recent
4
+ -- activity" (matching monet's conversations.updated_at semantics) and
5
+ -- callers can filter "active in the last hour" without re-folding the
6
+ -- event log. Maintained by appendEvents + updateSession at write time.
7
+ ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
8
+ UPDATE engram_sessions SET updated_at = created_at WHERE updated_at IS NULL;
9
+ ALTER TABLE engram_sessions ALTER COLUMN updated_at SET DEFAULT NOW();
10
+ ALTER TABLE engram_sessions ALTER COLUMN updated_at SET NOT NULL;
11
+
12
+ -- Index supporting the most common list-by-recent-activity query and
13
+ -- its status-filtered variant (status filter is cheap once the date
14
+ -- range scan is narrow).
15
+ CREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_updated
16
+ ON engram_sessions (workspace_id, updated_at DESC);
17
+ `;
@@ -2,6 +2,7 @@ import * as m0001 from "./0001-baseline";
2
2
  import * as m0002 from "./0002-aliases";
3
3
  import * as m0003 from "./0003-identities";
4
4
  import * as m0004 from "./0004-schema-completion";
5
+ import * as m0005 from "./0005-session-updated-at";
5
6
  /**
6
7
  * Schema migrations, applied in array order. Add a new file under
7
8
  * `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
@@ -14,4 +15,5 @@ export const MIGRATIONS = [
14
15
  { name: m0002.name, sql: m0002.sql },
15
16
  { name: m0003.name, sql: m0003.sql },
16
17
  { name: m0004.name, sql: m0004.sql },
18
+ { name: m0005.name, sql: m0005.sql },
17
19
  ];
@@ -0,0 +1,24 @@
1
+ import { Hono } from "hono";
2
+ import type { Env } from "../context";
3
+ import { type RouteConfig } from "./helpers";
4
+ /**
5
+ * Workspace-wide alias lookup. Mount under `/v1`:
6
+ * GET /v1/aliases?name=<name>
7
+ *
8
+ * The personal-scoped endpoints (`/v1/persons/:id/aliases`) cover
9
+ * "what aliases does this person have?". This top-level route covers
10
+ * the inverse — "which person(s) does this name resolve to?" — which
11
+ * hosts use to ground a free-text "who is X?" against the alias
12
+ * registry without pre-knowing the owning person.
13
+ *
14
+ * The match is case-insensitive (the alias table is keyed by
15
+ * `lower(name)`). Multiple persons in the same workspace can share an
16
+ * alias, so the response is an array ordered by `last_used` desc; the
17
+ * caller picks the most recently active match (or applies its own
18
+ * tie-breaker like caller/usage_count).
19
+ *
20
+ * Returns 400 when `?name=` is missing — alias lookups have no useful
21
+ * "list all aliases" semantics, so dropping the query param is treated
22
+ * as a request error rather than a full scan.
23
+ */
24
+ export declare function aliasesRoutes(_cfg: RouteConfig): Hono<Env>;
@@ -0,0 +1,41 @@
1
+ import { Hono } from "hono";
2
+ import { resolvePersonMap } from "./helpers";
3
+ /**
4
+ * Workspace-wide alias lookup. Mount under `/v1`:
5
+ * GET /v1/aliases?name=<name>
6
+ *
7
+ * The personal-scoped endpoints (`/v1/persons/:id/aliases`) cover
8
+ * "what aliases does this person have?". This top-level route covers
9
+ * the inverse — "which person(s) does this name resolve to?" — which
10
+ * hosts use to ground a free-text "who is X?" against the alias
11
+ * registry without pre-knowing the owning person.
12
+ *
13
+ * The match is case-insensitive (the alias table is keyed by
14
+ * `lower(name)`). Multiple persons in the same workspace can share an
15
+ * alias, so the response is an array ordered by `last_used` desc; the
16
+ * caller picks the most recently active match (or applies its own
17
+ * tie-breaker like caller/usage_count).
18
+ *
19
+ * Returns 400 when `?name=` is missing — alias lookups have no useful
20
+ * "list all aliases" semantics, so dropping the query param is treated
21
+ * as a request error rather than a full scan.
22
+ */
23
+ export function aliasesRoutes(_cfg) {
24
+ const app = new Hono();
25
+ app.get("/aliases", async (c) => {
26
+ const name = c.req.query("name");
27
+ if (!name) {
28
+ return c.json({
29
+ error: "name_required",
30
+ message: "GET /v1/aliases requires a ?name= query parameter",
31
+ }, 400);
32
+ }
33
+ const aliases = await c.var.ctx.storage.findAliasesByName(name);
34
+ // Inline the persons map so the caller can render a "candidate
35
+ // picker" UI without a second round-trip per match. Same envelope
36
+ // shape as the session list endpoints.
37
+ const persons = await resolvePersonMap(c.var.ctx.storage, aliases.map((a) => ({ participants: [a.person_id], viewable_by: [] })));
38
+ return c.json({ aliases, persons });
39
+ });
40
+ return app;
41
+ }
@@ -63,7 +63,15 @@ export function sessionsRoutes(cfg) {
63
63
  app.get("/sessions", async (c) => {
64
64
  const limit = clampLimit(c, cfg.defaultListLimit, cfg.maxListLimit);
65
65
  const channel = c.req.query("channel") || undefined;
66
- const sessions = await c.var.ctx.storage.listSessions({ limit, channel });
66
+ const rawStatus = c.req.query("status");
67
+ const status = rawStatus === "active" || rawStatus === "idle" || rawStatus === "completed"
68
+ ? rawStatus
69
+ : undefined;
70
+ const sessions = await c.var.ctx.storage.listSessions({
71
+ limit,
72
+ channel,
73
+ status,
74
+ });
67
75
  const persons = await resolvePersonMap(c.var.ctx.storage, sessions);
68
76
  return c.json({ sessions, persons });
69
77
  });
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { log, newRequestId } from "./logger";
3
3
  import { createAdminRouter } from "./admin";
4
+ import { aliasesRoutes } from "./routes/aliases";
4
5
  import { sessionsRoutes } from "./routes/sessions";
5
6
  import { personsRoutes } from "./routes/persons";
6
7
  import { identitiesRoutes } from "./routes/identities";
@@ -61,6 +62,7 @@ export function createServer(opts) {
61
62
  personSessions: "GET /v1/persons/:id/sessions",
62
63
  personAliases: "GET /v1/persons/:id/aliases",
63
64
  upsertAlias: "PUT /v1/persons/:id/aliases/:name",
65
+ findAliasesByName: "GET /v1/aliases?name=…",
64
66
  identityByRef: "GET/PUT /v1/identities/:ref",
65
67
  personIdentities: "GET /v1/persons/:id/identities",
66
68
  },
@@ -88,6 +90,7 @@ export function createServer(opts) {
88
90
  app.route("/v1", sessionsRoutes(cfg));
89
91
  app.route("/v1", personsRoutes(cfg));
90
92
  app.route("/v1", identitiesRoutes(cfg));
93
+ app.route("/v1", aliasesRoutes(cfg));
91
94
  app.route("/v1", searchRoutes(cfg));
92
95
  return app;
93
96
  }
package/dist/storage.d.ts CHANGED
@@ -33,10 +33,11 @@ export interface StorageAdapter {
33
33
  * events, which returns `[]`).
34
34
  */
35
35
  getSessionEvents(sessionId: string): Promise<SessionEvent[] | null>;
36
- /** List recent sessions. */
36
+ /** List recent sessions, ordered by `updated_at` desc. */
37
37
  listSessions(opts: {
38
38
  limit: number;
39
39
  channel?: string;
40
+ status?: "active" | "idle" | "completed";
40
41
  }): Promise<Session[]>;
41
42
  /**
42
43
  * Create a person with a freshly allocated id. The host (e.g. monet)
@@ -53,7 +54,12 @@ export interface StorageAdapter {
53
54
  getPerson(id: string): Promise<PersonInfo | null>;
54
55
  /** Batch fetch — used by response envelopes to inline `persons` maps. */
55
56
  getPersons(ids: string[]): Promise<PersonInfo[]>;
56
- /** List or search persons in this workspace. */
57
+ /**
58
+ * List or search persons in this workspace. The free-text query
59
+ * matches across id / display_name / role / team / linked identity
60
+ * display_names — so a search for "design" finds people with that
61
+ * role even if their display_name doesn't contain it.
62
+ */
57
63
  listPersons(opts: {
58
64
  limit: number;
59
65
  q?: string;
@@ -84,6 +90,16 @@ export interface StorageAdapter {
84
90
  } & AliasUpsert): Promise<AliasInfo | null>;
85
91
  /** A person's aliases, ordered newest-used-first. */
86
92
  listAliases(personId: string): Promise<AliasInfo[]>;
93
+ /**
94
+ * Workspace-wide lookup by alias name (case-insensitive). Returns
95
+ * every row whose `lower(name)` matches `lower(name)` across all
96
+ * persons in this workspace. Ordered by `last_used` desc — the
97
+ * caller can pick the most recently active match without re-sorting.
98
+ *
99
+ * Hosts use this to resolve a free-text "who is X?" query into a
100
+ * person id without pre-knowing which person owns the alias.
101
+ */
102
+ findAliasesByName(name: string): Promise<AliasInfo[]>;
87
103
  /**
88
104
  * Upsert by `ref` (e.g. `slack:U12345`). Idempotent: writing the same
89
105
  * ref multiple times converges. If the ref points to a different
@@ -109,6 +125,7 @@ export interface SessionRow {
109
125
  participants: string[];
110
126
  viewable_by: string[];
111
127
  createdAt: string;
128
+ updatedAt: string;
112
129
  status?: "active" | "idle" | "completed";
113
130
  summary?: string;
114
131
  model?: string;
package/dist/storage.js CHANGED
@@ -35,5 +35,6 @@ 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
+ updated_at: row.updatedAt,
38
39
  };
39
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexis-ai/engram-server",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Engram server: ingest agent session events, persist via a pluggable adapter, expose search.",
5
5
  "keywords": [
6
6
  "engram",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@hexis-ai/engram-core": "^0.1.5",
53
- "@hexis-ai/engram-sdk": "^0.7.0",
53
+ "@hexis-ai/engram-sdk": "^0.9.0",
54
54
  "hono": "^4.6.0",
55
55
  "zod": "^4.0.0"
56
56
  },