@hexis-ai/engram-server 0.8.0 → 0.9.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;
@@ -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);
@@ -257,6 +293,10 @@ export class InMemoryAdapter {
257
293
  return null;
258
294
  const now = new Date().toISOString();
259
295
  const existing = this.identities.get(ref);
296
+ // COALESCE-on-conflict for the soft-overlap fields; ref-level
297
+ // mapping (person_id, service, external_id, linked_at) always
298
+ // takes the latest input. unlinked_at uses undefined=no-change
299
+ // semantics so callers can re-link without explicitly clearing.
260
300
  const next = {
261
301
  ref,
262
302
  person_id: input.person_id,
@@ -266,11 +306,12 @@ export class InMemoryAdapter {
266
306
  ? input.display_name
267
307
  : existing?.display_name ?? null,
268
308
  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,
309
+ is_primary: input.is_primary ?? existing?.is_primary ?? false,
272
310
  picture: input.picture !== undefined ? input.picture : existing?.picture ?? null,
273
311
  linked_at: input.linked_at,
312
+ unlinked_at: input.unlinked_at !== undefined
313
+ ? input.unlinked_at
314
+ : existing?.unlinked_at ?? null,
274
315
  created_at: existing?.created_at ?? now,
275
316
  updated_at: now,
276
317
  };
@@ -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;
@@ -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
@@ -334,17 +378,21 @@ export class PostgresAdapter {
334
378
  `;
335
379
  if (personExists.length === 0)
336
380
  return null;
381
+ // unlinked_at semantics: undefined = leave alone, null = clear,
382
+ // value = set. matches the patch contract for the other fields.
383
+ const unlinkedProvided = input.unlinked_at !== undefined;
337
384
  const rows = await this.sql `
338
385
  INSERT INTO engram_identities (
339
386
  workspace_id, ref, person_id, service, external_id,
340
- display_name, source, is_primary, picture, linked_at
387
+ display_name, source, is_primary, picture, linked_at, unlinked_at
341
388
  )
342
389
  VALUES (
343
390
  ${this.workspaceId}, ${ref}, ${input.person_id},
344
391
  ${input.service}, ${input.external_id},
345
392
  ${input.display_name ?? null}, ${input.source ?? null},
346
- ${input.is_primary ?? null}, ${input.picture ?? null},
347
- ${input.linked_at}
393
+ ${input.is_primary ?? false}, ${input.picture ?? null},
394
+ ${input.linked_at},
395
+ ${input.unlinked_at ?? null}
348
396
  )
349
397
  ON CONFLICT (workspace_id, ref) DO UPDATE SET
350
398
  person_id = EXCLUDED.person_id,
@@ -352,19 +400,24 @@ export class PostgresAdapter {
352
400
  external_id = EXCLUDED.external_id,
353
401
  display_name = COALESCE(EXCLUDED.display_name, engram_identities.display_name),
354
402
  source = COALESCE(EXCLUDED.source, engram_identities.source),
355
- is_primary = COALESCE(EXCLUDED.is_primary, engram_identities.is_primary),
403
+ is_primary = EXCLUDED.is_primary,
356
404
  picture = COALESCE(EXCLUDED.picture, engram_identities.picture),
357
405
  linked_at = EXCLUDED.linked_at,
406
+ unlinked_at = CASE WHEN ${unlinkedProvided}
407
+ THEN ${input.unlinked_at ?? null}
408
+ ELSE engram_identities.unlinked_at END,
358
409
  updated_at = now()
359
410
  RETURNING ref, person_id, service, external_id, display_name, source,
360
- is_primary, picture, linked_at, created_at, updated_at
411
+ is_primary, picture, linked_at, unlinked_at,
412
+ created_at, updated_at
361
413
  `;
362
414
  return toIdentityInfo(rows[0]);
363
415
  }
364
416
  async getIdentityByRef(ref) {
365
417
  const rows = await this.sql `
366
418
  SELECT ref, person_id, service, external_id, display_name, source,
367
- is_primary, picture, linked_at, created_at, updated_at
419
+ is_primary, picture, linked_at, unlinked_at,
420
+ created_at, updated_at
368
421
  FROM engram_identities
369
422
  WHERE workspace_id = ${this.workspaceId} AND ref = ${ref}
370
423
  LIMIT 1
@@ -376,7 +429,8 @@ export class PostgresAdapter {
376
429
  async listIdentitiesByPerson(personId) {
377
430
  const rows = await this.sql `
378
431
  SELECT ref, person_id, service, external_id, display_name, source,
379
- is_primary, picture, linked_at, created_at, updated_at
432
+ is_primary, picture, linked_at, unlinked_at,
433
+ created_at, updated_at
380
434
  FROM engram_identities
381
435
  WHERE workspace_id = ${this.workspaceId} AND person_id = ${personId}
382
436
  ORDER BY linked_at DESC
@@ -389,20 +443,23 @@ function toPersonInfo(r) {
389
443
  return {
390
444
  id: r.id,
391
445
  display_name: r.display_name,
446
+ role: r.role,
447
+ team: r.team,
448
+ source: r.source,
392
449
  created_at: toIso(r.created_at),
393
450
  updated_at: toIso(r.updated_at),
394
451
  };
395
452
  }
396
453
  function toSessionRow(r) {
454
+ const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
397
455
  return {
398
456
  id: r.id,
399
457
  ...(r.title ? { title: r.title } : {}),
400
458
  ...(r.channel ? { channel: r.channel } : {}),
401
459
  participants: r.participants,
402
460
  viewable_by: r.viewable_by,
403
- createdAt: typeof r.created_at === "string"
404
- ? r.created_at
405
- : r.created_at.toISOString(),
461
+ createdAt: toIso(r.created_at),
462
+ updatedAt: toIso(r.updated_at),
406
463
  ...(r.status === "active" || r.status === "idle" || r.status === "completed"
407
464
  ? { status: r.status }
408
465
  : {}),
@@ -435,9 +492,7 @@ function toAliasInfo(r) {
435
492
  }
436
493
  function toIdentityInfo(r) {
437
494
  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);
495
+ // linked_at is now TIMESTAMPTZ (post-migration 0004). Emit full ISO.
441
496
  return {
442
497
  ref: r.ref,
443
498
  person_id: r.person_id,
@@ -447,7 +502,8 @@ function toIdentityInfo(r) {
447
502
  source: r.source,
448
503
  is_primary: r.is_primary,
449
504
  picture: r.picture,
450
- linked_at: linkedAt,
505
+ linked_at: toIso(r.linked_at),
506
+ unlinked_at: r.unlinked_at === null ? null : toIso(r.unlinked_at),
451
507
  created_at: toIso(r.created_at),
452
508
  updated_at: toIso(r.updated_at),
453
509
  };
@@ -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
  ];
@@ -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/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;
@@ -109,6 +115,7 @@ export interface SessionRow {
109
115
  participants: string[];
110
116
  viewable_by: string[];
111
117
  createdAt: string;
118
+ updatedAt: string;
112
119
  status?: "active" | "idle" | "completed";
113
120
  summary?: string;
114
121
  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.9.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.8.0",
54
54
  "hono": "^4.6.0",
55
55
  "zod": "^4.0.0"
56
56
  },