@hexis-ai/engram-server 0.7.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.
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit, SessionUpdate } from "@hexis-ai/engram-sdk";
3
3
  import { type StorageAdapter } from "../storage";
4
4
  export interface InMemoryAdapterOptions {
5
5
  /** Override for tests. Default: `p_${random}` with 8 chars. */
@@ -24,10 +24,12 @@ export declare class InMemoryAdapter implements StorageAdapter {
24
24
  }): Promise<void>;
25
25
  appendEvents(sessionId: string, events: SessionEvent[]): Promise<void>;
26
26
  getSession(sessionId: string): Promise<Session | null>;
27
+ updateSession(sessionId: string, patch: SessionUpdate): Promise<Session | null>;
27
28
  getSessionEvents(sessionId: string): Promise<SessionEvent[] | null>;
28
29
  listSessions(opts: {
29
30
  limit: number;
30
31
  channel?: string;
32
+ status?: "active" | "idle" | "completed";
31
33
  }): Promise<Session[]>;
32
34
  sessionsForPerson(personId: string, opts: {
33
35
  limit: number;
@@ -37,6 +37,17 @@ export class InMemoryAdapter {
37
37
  participants,
38
38
  viewable_by,
39
39
  createdAt: init.createdAt,
40
+ updatedAt: init.createdAt,
41
+ // status defaults to 'active' to match the Postgres column default.
42
+ status: init.status ?? "active",
43
+ ...(init.summary != null ? { summary: init.summary } : {}),
44
+ ...(init.model != null ? { model: init.model } : {}),
45
+ ...(init.trigger_conversation_id != null
46
+ ? { trigger_conversation_id: init.trigger_conversation_id }
47
+ : {}),
48
+ ...(init.trigger_event_id != null
49
+ ? { trigger_event_id: init.trigger_event_id }
50
+ : {}),
40
51
  },
41
52
  events: new Map(),
42
53
  });
@@ -59,6 +70,13 @@ export class InMemoryAdapter {
59
70
  };
60
71
  }
61
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
+ }
62
80
  }
63
81
  async getSession(sessionId) {
64
82
  const s = this.sessions.get(sessionId);
@@ -66,6 +84,57 @@ export class InMemoryAdapter {
66
84
  return null;
67
85
  return foldEvents(s.row, [...s.events.values()], new Date());
68
86
  }
87
+ async updateSession(sessionId, patch) {
88
+ const s = this.sessions.get(sessionId);
89
+ if (!s)
90
+ return null;
91
+ // Patch semantics: undefined = leave alone; null = clear; value = set.
92
+ // SessionRow optional fields use `string | undefined`, so a "null"
93
+ // request collapses to `undefined` storage-side.
94
+ const next = { ...s.row };
95
+ if (patch.title !== undefined) {
96
+ if (patch.title === null)
97
+ delete next.title;
98
+ else
99
+ next.title = patch.title;
100
+ }
101
+ if (patch.channel !== undefined) {
102
+ if (patch.channel === null)
103
+ delete next.channel;
104
+ else
105
+ next.channel = patch.channel;
106
+ }
107
+ if (patch.status !== undefined) {
108
+ next.status = patch.status;
109
+ }
110
+ if (patch.summary !== undefined) {
111
+ if (patch.summary === null)
112
+ delete next.summary;
113
+ else
114
+ next.summary = patch.summary;
115
+ }
116
+ if (patch.model !== undefined) {
117
+ if (patch.model === null)
118
+ delete next.model;
119
+ else
120
+ next.model = patch.model;
121
+ }
122
+ if (patch.trigger_conversation_id !== undefined) {
123
+ if (patch.trigger_conversation_id === null)
124
+ delete next.trigger_conversation_id;
125
+ else
126
+ next.trigger_conversation_id = patch.trigger_conversation_id;
127
+ }
128
+ if (patch.trigger_event_id !== undefined) {
129
+ if (patch.trigger_event_id === null)
130
+ delete next.trigger_event_id;
131
+ else
132
+ next.trigger_event_id = patch.trigger_event_id;
133
+ }
134
+ next.updatedAt = new Date().toISOString();
135
+ s.row = next;
136
+ return foldEvents(s.row, [...s.events.values()], new Date());
137
+ }
69
138
  async getSessionEvents(sessionId) {
70
139
  const s = this.sessions.get(sessionId);
71
140
  if (!s)
@@ -78,12 +147,14 @@ export class InMemoryAdapter {
78
147
  for (const stored of this.sessions.values()) {
79
148
  if (opts.channel && stored.row.channel !== opts.channel)
80
149
  continue;
150
+ if (opts.status && stored.row.status !== opts.status)
151
+ continue;
81
152
  all.push({
82
153
  s: foldEvents(stored.row, [...stored.events.values()], now),
83
- createdAt: stored.row.createdAt,
154
+ updatedAt: stored.row.updatedAt,
84
155
  });
85
156
  }
86
- all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
157
+ all.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
87
158
  return all.slice(0, opts.limit).map((x) => x.s);
88
159
  }
89
160
  async sessionsForPerson(personId, opts) {
@@ -112,15 +183,17 @@ export class InMemoryAdapter {
112
183
  async upsertPerson(id, input) {
113
184
  const now = new Date().toISOString();
114
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.
115
189
  const next = {
116
190
  id,
117
- // `PersonCreate` has no notion of "clear the name" — that is
118
- // `updatePerson`'s job. A missing value (and, defensively, an
119
- // explicit null that bypasses the schema) means "keep what's
120
- // there", matching the Postgres adapter's `COALESCE(EXCLUDED.…)`.
121
191
  display_name: input.display_name != null
122
192
  ? input.display_name
123
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",
124
197
  created_at: existing?.created_at ?? now,
125
198
  updated_at: now,
126
199
  };
@@ -132,9 +205,13 @@ export class InMemoryAdapter {
132
205
  if (!existing)
133
206
  return null;
134
207
  const now = new Date().toISOString();
208
+ // undefined = no-op, null = clear (per the SDK contract).
135
209
  const next = {
136
210
  ...existing,
137
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",
138
215
  updated_at: now,
139
216
  };
140
217
  this.persons.set(id, next);
@@ -155,9 +232,28 @@ export class InMemoryAdapter {
155
232
  async listPersons(opts) {
156
233
  const q = opts.q?.trim().toLowerCase() ?? "";
157
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.
158
238
  const matched = q
159
- ? all.filter((p) => p.id.toLowerCase().includes(q) ||
160
- (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
+ })
161
257
  : all;
162
258
  matched.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
163
259
  return matched.slice(0, opts.limit);
@@ -197,6 +293,10 @@ export class InMemoryAdapter {
197
293
  return null;
198
294
  const now = new Date().toISOString();
199
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.
200
300
  const next = {
201
301
  ref,
202
302
  person_id: input.person_id,
@@ -206,11 +306,12 @@ export class InMemoryAdapter {
206
306
  ? input.display_name
207
307
  : existing?.display_name ?? null,
208
308
  source: input.source !== undefined ? input.source : existing?.source ?? null,
209
- is_primary: input.is_primary !== undefined
210
- ? input.is_primary
211
- : existing?.is_primary ?? null,
309
+ is_primary: input.is_primary ?? existing?.is_primary ?? false,
212
310
  picture: input.picture !== undefined ? input.picture : existing?.picture ?? null,
213
311
  linked_at: input.linked_at,
312
+ unlinked_at: input.unlinked_at !== undefined
313
+ ? input.unlinked_at
314
+ : existing?.unlinked_at ?? null,
214
315
  created_at: existing?.created_at ?? now,
215
316
  updated_at: now,
216
317
  };
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit, SessionUpdate } from "@hexis-ai/engram-sdk";
3
3
  import { type StorageAdapter } from "../storage";
4
4
  /**
5
5
  * Minimal subset of `postgres` driver's tagged-template surface that this
@@ -37,10 +37,12 @@ export declare class PostgresAdapter implements StorageAdapter {
37
37
  }): Promise<void>;
38
38
  appendEvents(sessionId: string, events: SessionEvent[]): Promise<void>;
39
39
  getSession(sessionId: string): Promise<Session | null>;
40
+ updateSession(sessionId: string, patch: SessionUpdate): Promise<Session | null>;
40
41
  getSessionEvents(sessionId: string): Promise<SessionEvent[] | null>;
41
42
  listSessions(opts: {
42
43
  limit: number;
43
44
  channel?: string;
45
+ status?: "active" | "idle" | "completed";
44
46
  }): Promise<Session[]>;
45
47
  sessionsForPerson(personId: string, opts: {
46
48
  limit: number;
@@ -38,8 +38,14 @@ export class PostgresAdapter {
38
38
  const viewableBy = init.viewable_by
39
39
  ? Array.from(new Set([...init.viewable_by, ...participants]))
40
40
  : [...participants];
41
+ // status defaults to 'active' server-side via the column default;
42
+ // a null value here is treated as "use the default" by NULL-fallback.
41
43
  await this.sql `
42
- INSERT INTO engram_sessions (workspace_id, id, title, channel, participants, viewable_by, created_at)
44
+ INSERT INTO engram_sessions (
45
+ workspace_id, id, title, channel, participants, viewable_by,
46
+ created_at, updated_at, status, summary, model,
47
+ trigger_conversation_id, trigger_event_id
48
+ )
43
49
  VALUES (
44
50
  ${this.workspaceId},
45
51
  ${init.id},
@@ -47,7 +53,13 @@ export class PostgresAdapter {
47
53
  ${init.channel ?? null},
48
54
  ${participants},
49
55
  ${viewableBy},
50
- ${init.createdAt}
56
+ ${init.createdAt},
57
+ ${init.createdAt},
58
+ ${init.status ?? "active"},
59
+ ${init.summary ?? null},
60
+ ${init.model ?? null},
61
+ ${init.trigger_conversation_id ?? null},
62
+ ${init.trigger_event_id ?? null}
51
63
  )
52
64
  ON CONFLICT (workspace_id, id) DO NOTHING
53
65
  `;
@@ -84,31 +96,65 @@ export class PostgresAdapter {
84
96
  `;
85
97
  }
86
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
+ `;
87
108
  }
88
109
  async getSession(sessionId) {
89
110
  const rows = await this.sql `
90
- SELECT id, title, channel, participants, viewable_by, created_at
111
+ SELECT id, title, channel, participants, viewable_by, created_at,
112
+ updated_at, status, summary, model,
113
+ trigger_conversation_id, trigger_event_id
91
114
  FROM engram_sessions
92
115
  WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
93
116
  LIMIT 1
94
117
  `;
95
118
  if (rows.length === 0)
96
119
  return null;
97
- const r = rows[0];
98
120
  const events = await this.sql `
99
121
  SELECT payload FROM engram_events
100
122
  WHERE workspace_id = ${this.workspaceId} AND session_id = ${sessionId}
101
123
  ORDER BY seq
102
124
  `;
103
- const row = {
104
- id: r.id,
105
- ...(r.title ? { title: r.title } : {}),
106
- ...(r.channel ? { channel: r.channel } : {}),
107
- participants: r.participants,
108
- viewable_by: r.viewable_by,
109
- createdAt: typeof r.created_at === "string" ? r.created_at : r.created_at.toISOString(),
110
- };
111
- return foldEvents(row, events.map((e) => e.payload), new Date());
125
+ return foldEvents(toSessionRow(rows[0]), events.map((e) => e.payload), new Date());
126
+ }
127
+ async updateSession(sessionId, patch) {
128
+ // Translate JS undefined "no change" via a per-column "provided"
129
+ // flag the SQL evaluates with CASE WHEN. null in patch becomes a
130
+ // real null in the DB (clear), value becomes a set.
131
+ const titleProvided = patch.title !== undefined;
132
+ const channelProvided = patch.channel !== undefined;
133
+ const statusProvided = patch.status !== undefined;
134
+ const summaryProvided = patch.summary !== undefined;
135
+ const modelProvided = patch.model !== undefined;
136
+ const tcIdProvided = patch.trigger_conversation_id !== undefined;
137
+ const teIdProvided = patch.trigger_event_id !== undefined;
138
+ const rows = await this.sql `
139
+ UPDATE engram_sessions SET
140
+ title = CASE WHEN ${titleProvided} THEN ${patch.title ?? null} ELSE title END,
141
+ channel = CASE WHEN ${channelProvided} THEN ${patch.channel ?? null} ELSE channel END,
142
+ status = CASE WHEN ${statusProvided} THEN ${patch.status ?? "active"} ELSE status END,
143
+ summary = CASE WHEN ${summaryProvided} THEN ${patch.summary ?? null} ELSE summary END,
144
+ model = CASE WHEN ${modelProvided} THEN ${patch.model ?? null} ELSE model END,
145
+ trigger_conversation_id = CASE WHEN ${tcIdProvided}
146
+ THEN ${patch.trigger_conversation_id ?? null}
147
+ ELSE trigger_conversation_id END,
148
+ trigger_event_id = CASE WHEN ${teIdProvided}
149
+ THEN ${patch.trigger_event_id ?? null}
150
+ ELSE trigger_event_id END,
151
+ updated_at = now()
152
+ WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
153
+ RETURNING id
154
+ `;
155
+ if (rows.length === 0)
156
+ return null;
157
+ return this.getSession(sessionId);
112
158
  }
113
159
  async getSessionEvents(sessionId) {
114
160
  const rows = await this.sql `
@@ -127,11 +173,13 @@ export class PostgresAdapter {
127
173
  }
128
174
  async listSessions(opts) {
129
175
  const channelFilter = opts.channel ?? null;
176
+ const statusFilter = opts.status ?? null;
130
177
  const rows = await this.sql `
131
178
  SELECT id FROM engram_sessions
132
179
  WHERE workspace_id = ${this.workspaceId}
133
180
  AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
134
- ORDER BY created_at DESC
181
+ AND (${statusFilter}::text IS NULL OR status = ${statusFilter}::text)
182
+ ORDER BY updated_at DESC
135
183
  LIMIT ${opts.limit}
136
184
  `;
137
185
  const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
@@ -169,24 +217,41 @@ export class PostgresAdapter {
169
217
  }
170
218
  async upsertPerson(id, input) {
171
219
  const rows = await this.sql `
172
- INSERT INTO engram_persons (workspace_id, id, display_name)
173
- 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
+ )
174
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.
175
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),
176
235
  updated_at = now()
177
- RETURNING id, display_name, created_at, updated_at
236
+ RETURNING id, display_name, role, team, source, created_at, updated_at
178
237
  `;
179
238
  return toPersonInfo(rows[0]);
180
239
  }
181
240
  async updatePerson(id, patch) {
182
241
  // Treat `null` as an explicit clear; `undefined` as no-op.
183
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;
184
246
  const rows = await this.sql `
185
- UPDATE engram_persons
186
- SET display_name = CASE WHEN ${nameProvided} THEN ${patch.display_name ?? null} ELSE display_name END,
187
- 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()
188
253
  WHERE workspace_id = ${this.workspaceId} AND id = ${id}
189
- RETURNING id, display_name, created_at, updated_at
254
+ RETURNING id, display_name, role, team, source, created_at, updated_at
190
255
  `;
191
256
  if (rows.length === 0)
192
257
  return null;
@@ -194,7 +259,7 @@ export class PostgresAdapter {
194
259
  }
195
260
  async getPerson(id) {
196
261
  const rows = await this.sql `
197
- SELECT id, display_name, created_at, updated_at
262
+ SELECT id, display_name, role, team, source, created_at, updated_at
198
263
  FROM engram_persons
199
264
  WHERE workspace_id = ${this.workspaceId} AND id = ${id}
200
265
  LIMIT 1
@@ -207,7 +272,7 @@ export class PostgresAdapter {
207
272
  if (ids.length === 0)
208
273
  return [];
209
274
  const rows = await this.sql `
210
- SELECT id, display_name, created_at, updated_at
275
+ SELECT id, display_name, role, team, source, created_at, updated_at
211
276
  FROM engram_persons
212
277
  WHERE workspace_id = ${this.workspaceId}
213
278
  AND id = ANY(${ids}::text[])
@@ -218,18 +283,32 @@ export class PostgresAdapter {
218
283
  const q = opts.q?.trim() ?? "";
219
284
  if (q) {
220
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).
221
289
  const rows = await this.sql `
222
- SELECT id, display_name, created_at, updated_at
223
- FROM engram_persons
224
- WHERE workspace_id = ${this.workspaceId}
225
- AND (lower(id) LIKE ${pattern} OR lower(coalesce(display_name, '')) LIKE ${pattern})
226
- 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
227
306
  LIMIT ${opts.limit}
228
307
  `;
229
308
  return rows.map(toPersonInfo);
230
309
  }
231
310
  const rows = await this.sql `
232
- SELECT id, display_name, created_at, updated_at
311
+ SELECT id, display_name, role, team, source, created_at, updated_at
233
312
  FROM engram_persons
234
313
  WHERE workspace_id = ${this.workspaceId}
235
314
  ORDER BY updated_at DESC
@@ -299,17 +378,21 @@ export class PostgresAdapter {
299
378
  `;
300
379
  if (personExists.length === 0)
301
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;
302
384
  const rows = await this.sql `
303
385
  INSERT INTO engram_identities (
304
386
  workspace_id, ref, person_id, service, external_id,
305
- display_name, source, is_primary, picture, linked_at
387
+ display_name, source, is_primary, picture, linked_at, unlinked_at
306
388
  )
307
389
  VALUES (
308
390
  ${this.workspaceId}, ${ref}, ${input.person_id},
309
391
  ${input.service}, ${input.external_id},
310
392
  ${input.display_name ?? null}, ${input.source ?? null},
311
- ${input.is_primary ?? null}, ${input.picture ?? null},
312
- ${input.linked_at}
393
+ ${input.is_primary ?? false}, ${input.picture ?? null},
394
+ ${input.linked_at},
395
+ ${input.unlinked_at ?? null}
313
396
  )
314
397
  ON CONFLICT (workspace_id, ref) DO UPDATE SET
315
398
  person_id = EXCLUDED.person_id,
@@ -317,19 +400,24 @@ export class PostgresAdapter {
317
400
  external_id = EXCLUDED.external_id,
318
401
  display_name = COALESCE(EXCLUDED.display_name, engram_identities.display_name),
319
402
  source = COALESCE(EXCLUDED.source, engram_identities.source),
320
- is_primary = COALESCE(EXCLUDED.is_primary, engram_identities.is_primary),
403
+ is_primary = EXCLUDED.is_primary,
321
404
  picture = COALESCE(EXCLUDED.picture, engram_identities.picture),
322
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,
323
409
  updated_at = now()
324
410
  RETURNING ref, person_id, service, external_id, display_name, source,
325
- is_primary, picture, linked_at, created_at, updated_at
411
+ is_primary, picture, linked_at, unlinked_at,
412
+ created_at, updated_at
326
413
  `;
327
414
  return toIdentityInfo(rows[0]);
328
415
  }
329
416
  async getIdentityByRef(ref) {
330
417
  const rows = await this.sql `
331
418
  SELECT ref, person_id, service, external_id, display_name, source,
332
- is_primary, picture, linked_at, created_at, updated_at
419
+ is_primary, picture, linked_at, unlinked_at,
420
+ created_at, updated_at
333
421
  FROM engram_identities
334
422
  WHERE workspace_id = ${this.workspaceId} AND ref = ${ref}
335
423
  LIMIT 1
@@ -341,7 +429,8 @@ export class PostgresAdapter {
341
429
  async listIdentitiesByPerson(personId) {
342
430
  const rows = await this.sql `
343
431
  SELECT ref, person_id, service, external_id, display_name, source,
344
- is_primary, picture, linked_at, created_at, updated_at
432
+ is_primary, picture, linked_at, unlinked_at,
433
+ created_at, updated_at
345
434
  FROM engram_identities
346
435
  WHERE workspace_id = ${this.workspaceId} AND person_id = ${personId}
347
436
  ORDER BY linked_at DESC
@@ -354,10 +443,36 @@ function toPersonInfo(r) {
354
443
  return {
355
444
  id: r.id,
356
445
  display_name: r.display_name,
446
+ role: r.role,
447
+ team: r.team,
448
+ source: r.source,
357
449
  created_at: toIso(r.created_at),
358
450
  updated_at: toIso(r.updated_at),
359
451
  };
360
452
  }
453
+ function toSessionRow(r) {
454
+ const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
455
+ return {
456
+ id: r.id,
457
+ ...(r.title ? { title: r.title } : {}),
458
+ ...(r.channel ? { channel: r.channel } : {}),
459
+ participants: r.participants,
460
+ viewable_by: r.viewable_by,
461
+ createdAt: toIso(r.created_at),
462
+ updatedAt: toIso(r.updated_at),
463
+ ...(r.status === "active" || r.status === "idle" || r.status === "completed"
464
+ ? { status: r.status }
465
+ : {}),
466
+ ...(r.summary !== null ? { summary: r.summary } : {}),
467
+ ...(r.model !== null ? { model: r.model } : {}),
468
+ ...(r.trigger_conversation_id !== null
469
+ ? { trigger_conversation_id: r.trigger_conversation_id }
470
+ : {}),
471
+ ...(r.trigger_event_id !== null
472
+ ? { trigger_event_id: r.trigger_event_id }
473
+ : {}),
474
+ };
475
+ }
361
476
  function toAliasInfo(r) {
362
477
  const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
363
478
  // last_used is a DATE column; postgres-js returns it as a Date at UTC
@@ -377,9 +492,7 @@ function toAliasInfo(r) {
377
492
  }
378
493
  function toIdentityInfo(r) {
379
494
  const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
380
- const linkedAt = typeof r.linked_at === "string"
381
- ? r.linked_at.slice(0, 10)
382
- : r.linked_at.toISOString().slice(0, 10);
495
+ // linked_at is now TIMESTAMPTZ (post-migration 0004). Emit full ISO.
383
496
  return {
384
497
  ref: r.ref,
385
498
  person_id: r.person_id,
@@ -389,7 +502,8 @@ function toIdentityInfo(r) {
389
502
  source: r.source,
390
503
  is_primary: r.is_primary,
391
504
  picture: r.picture,
392
- linked_at: linkedAt,
505
+ linked_at: toIso(r.linked_at),
506
+ unlinked_at: r.unlinked_at === null ? null : toIso(r.unlinked_at),
393
507
  created_at: toIso(r.created_at),
394
508
  updated_at: toIso(r.updated_at),
395
509
  };
@@ -0,0 +1,2 @@
1
+ export declare const name = "0004-schema-completion";
2
+ export declare const sql = "\n-- Wave 1 of the engram-as-sole-memory-source migration. Adds the\n-- richer columns monet needs to retire its in-tree memory tables, and\n-- tightens a few earlier schema choices before launch freezes the wire.\n\n-- Persons gain organizational fields. Previously engram was the\n-- retrieval primitive only, so role/team/source were intentionally\n-- absent (\"HR data belongs upstream\"). Now that engram is monet's\n-- canonical memory layer, those facts have to live here.\nALTER TABLE engram_persons ADD COLUMN IF NOT EXISTS role TEXT;\nALTER TABLE engram_persons ADD COLUMN IF NOT EXISTS team TEXT;\nALTER TABLE engram_persons ADD COLUMN IF NOT EXISTS source TEXT;\nUPDATE engram_persons SET source = 'auto' WHERE source IS NULL;\nALTER TABLE engram_persons ALTER COLUMN source SET DEFAULT 'auto';\nALTER TABLE engram_persons ALTER COLUMN source SET NOT NULL;\n\n-- Sessions gain monet-derived metadata so the host's conversation_*\n-- columns disappear.\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS status TEXT;\nUPDATE engram_sessions SET status = 'active' WHERE status IS NULL;\nALTER TABLE engram_sessions ALTER COLUMN status SET DEFAULT 'active';\nALTER TABLE engram_sessions ALTER COLUMN status SET NOT NULL;\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS summary TEXT;\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS model TEXT;\n-- A session can be triggered by another conversation (manual fan-out)\n-- or by an external event id (calendar event in monet, etc.). No FK on\n-- trigger_event_id \u2014 it crosses systems.\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS trigger_conversation_id TEXT;\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS trigger_event_id TEXT;\n\n-- Identities tightening \u2014 last chance to fix these before launch.\n-- is_primary: nullable BOOLEAN where null/false were ambiguous \u2192\n-- pin to NOT NULL DEFAULT false.\n-- unlinked_at: new \u2014 represents \"this external connection was\n-- revoked\", which the append-via-upsert model previously couldn't\n-- express. nullable; non-null means the identity is no longer\n-- authoritative.\n-- linked_at: was DATE (lossy). Widen to TIMESTAMPTZ to preserve\n-- full timing. The cast is a no-op when already TIMESTAMPTZ.\nUPDATE engram_identities SET is_primary = false WHERE is_primary IS NULL;\nALTER TABLE engram_identities ALTER COLUMN is_primary SET DEFAULT false;\nALTER TABLE engram_identities ALTER COLUMN is_primary SET NOT NULL;\nALTER TABLE engram_identities ADD COLUMN IF NOT EXISTS unlinked_at TIMESTAMPTZ;\nALTER TABLE engram_identities ALTER COLUMN linked_at TYPE TIMESTAMPTZ\n USING linked_at::TIMESTAMPTZ;\n\n-- Index supporting \"active identities only\" queries \u2014 common after\n-- adding unlinked_at since most callers want non-revoked rows.\nCREATE INDEX IF NOT EXISTS idx_engram_identities_active_service_external\n ON engram_identities (workspace_id, service, external_id)\n WHERE unlinked_at IS NULL;\n";
@@ -0,0 +1,53 @@
1
+ export const name = "0004-schema-completion";
2
+ export const sql = `
3
+ -- Wave 1 of the engram-as-sole-memory-source migration. Adds the
4
+ -- richer columns monet needs to retire its in-tree memory tables, and
5
+ -- tightens a few earlier schema choices before launch freezes the wire.
6
+
7
+ -- Persons gain organizational fields. Previously engram was the
8
+ -- retrieval primitive only, so role/team/source were intentionally
9
+ -- absent ("HR data belongs upstream"). Now that engram is monet's
10
+ -- canonical memory layer, those facts have to live here.
11
+ ALTER TABLE engram_persons ADD COLUMN IF NOT EXISTS role TEXT;
12
+ ALTER TABLE engram_persons ADD COLUMN IF NOT EXISTS team TEXT;
13
+ ALTER TABLE engram_persons ADD COLUMN IF NOT EXISTS source TEXT;
14
+ UPDATE engram_persons SET source = 'auto' WHERE source IS NULL;
15
+ ALTER TABLE engram_persons ALTER COLUMN source SET DEFAULT 'auto';
16
+ ALTER TABLE engram_persons ALTER COLUMN source SET NOT NULL;
17
+
18
+ -- Sessions gain monet-derived metadata so the host's conversation_*
19
+ -- columns disappear.
20
+ ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS status TEXT;
21
+ UPDATE engram_sessions SET status = 'active' WHERE status IS NULL;
22
+ ALTER TABLE engram_sessions ALTER COLUMN status SET DEFAULT 'active';
23
+ ALTER TABLE engram_sessions ALTER COLUMN status SET NOT NULL;
24
+ ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS summary TEXT;
25
+ ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS model TEXT;
26
+ -- A session can be triggered by another conversation (manual fan-out)
27
+ -- or by an external event id (calendar event in monet, etc.). No FK on
28
+ -- trigger_event_id — it crosses systems.
29
+ ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS trigger_conversation_id TEXT;
30
+ ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS trigger_event_id TEXT;
31
+
32
+ -- Identities tightening — last chance to fix these before launch.
33
+ -- is_primary: nullable BOOLEAN where null/false were ambiguous →
34
+ -- pin to NOT NULL DEFAULT false.
35
+ -- unlinked_at: new — represents "this external connection was
36
+ -- revoked", which the append-via-upsert model previously couldn't
37
+ -- express. nullable; non-null means the identity is no longer
38
+ -- authoritative.
39
+ -- linked_at: was DATE (lossy). Widen to TIMESTAMPTZ to preserve
40
+ -- full timing. The cast is a no-op when already TIMESTAMPTZ.
41
+ UPDATE engram_identities SET is_primary = false WHERE is_primary IS NULL;
42
+ ALTER TABLE engram_identities ALTER COLUMN is_primary SET DEFAULT false;
43
+ ALTER TABLE engram_identities ALTER COLUMN is_primary SET NOT NULL;
44
+ ALTER TABLE engram_identities ADD COLUMN IF NOT EXISTS unlinked_at TIMESTAMPTZ;
45
+ ALTER TABLE engram_identities ALTER COLUMN linked_at TYPE TIMESTAMPTZ
46
+ USING linked_at::TIMESTAMPTZ;
47
+
48
+ -- Index supporting "active identities only" queries — common after
49
+ -- adding unlinked_at since most callers want non-revoked rows.
50
+ CREATE INDEX IF NOT EXISTS idx_engram_identities_active_service_external
51
+ ON engram_identities (workspace_id, service, external_id)
52
+ WHERE unlinked_at IS NULL;
53
+ `;
@@ -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
+ `;
@@ -1,6 +1,8 @@
1
1
  import * as m0001 from "./0001-baseline";
2
2
  import * as m0002 from "./0002-aliases";
3
3
  import * as m0003 from "./0003-identities";
4
+ import * as m0004 from "./0004-schema-completion";
5
+ import * as m0005 from "./0005-session-updated-at";
4
6
  /**
5
7
  * Schema migrations, applied in array order. Add a new file under
6
8
  * `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
@@ -12,4 +14,6 @@ export const MIGRATIONS = [
12
14
  { name: m0001.name, sql: m0001.sql },
13
15
  { name: m0002.name, sql: m0002.sql },
14
16
  { name: m0003.name, sql: m0003.sql },
17
+ { name: m0004.name, sql: m0004.sql },
18
+ { name: m0005.name, sql: m0005.sql },
15
19
  ];
package/dist/openapi.js CHANGED
@@ -128,6 +128,17 @@ function buildPaths() {
128
128
  "401": res("認証エラー"),
129
129
  },
130
130
  },
131
+ patch: {
132
+ summary: "セッションのメタデータ(title / channel / status / summary / model / trigger_*)を部分更新する。",
133
+ parameters: [pathParam("id", "セッション id。")],
134
+ requestBody: jsonBody("SessionUpdate"),
135
+ responses: {
136
+ "200": res("更新後のセッションのエンベロープ"),
137
+ "400": res("リクエストボディが不正"),
138
+ "404": res("セッションが見つからない"),
139
+ "401": res("認証エラー"),
140
+ },
141
+ },
131
142
  }),
132
143
  "/v1/sessions/{id}/events": tagged("Sessions", {
133
144
  post: {
@@ -3,10 +3,11 @@ import type { Env } from "../context";
3
3
  import { type RouteConfig } from "./helpers";
4
4
  /**
5
5
  * Session routes. Mount under `/v1`:
6
- * POST /v1/sessions create a session
7
- * POST /v1/sessions/:id/events append events
8
- * GET /v1/sessions/:id fetch one session + its persons map
9
- * GET /v1/sessions/:id/events fetch the raw, ordered event log
10
- * GET /v1/sessions list recent sessions + persons map
6
+ * POST /v1/sessions create a session
7
+ * PATCH /v1/sessions/:id update session-level metadata
8
+ * POST /v1/sessions/:id/events append events
9
+ * GET /v1/sessions/:id fetch one session + its persons map
10
+ * GET /v1/sessions/:id/events fetch the raw, ordered event log
11
+ * GET /v1/sessions list recent sessions + persons map
11
12
  */
12
13
  export declare function sessionsRoutes(cfg: RouteConfig): Hono<Env>;
@@ -1,13 +1,14 @@
1
1
  import { Hono } from "hono";
2
- import { eventBatchSchema, parseJsonBody, sessionInitSchema } from "../schemas";
2
+ import { eventBatchSchema, parseJsonBody, sessionInitSchema, sessionUpdateSchema, } from "../schemas";
3
3
  import { clampLimit, resolvePersonMap } from "./helpers";
4
4
  /**
5
5
  * Session routes. Mount under `/v1`:
6
- * POST /v1/sessions create a session
7
- * POST /v1/sessions/:id/events append events
8
- * GET /v1/sessions/:id fetch one session + its persons map
9
- * GET /v1/sessions/:id/events fetch the raw, ordered event log
10
- * GET /v1/sessions list recent sessions + persons map
6
+ * POST /v1/sessions create a session
7
+ * PATCH /v1/sessions/:id update session-level metadata
8
+ * POST /v1/sessions/:id/events append events
9
+ * GET /v1/sessions/:id fetch one session + its persons map
10
+ * GET /v1/sessions/:id/events fetch the raw, ordered event log
11
+ * GET /v1/sessions list recent sessions + persons map
11
12
  */
12
13
  export function sessionsRoutes(cfg) {
13
14
  const app = new Hono();
@@ -33,6 +34,17 @@ export function sessionsRoutes(cfg) {
33
34
  }
34
35
  return c.body(null, 204);
35
36
  });
37
+ app.patch("/sessions/:id", async (c) => {
38
+ const id = c.req.param("id");
39
+ const body = await parseJsonBody(c, sessionUpdateSchema);
40
+ if (body instanceof Response)
41
+ return body;
42
+ const session = await c.var.ctx.storage.updateSession(id, body);
43
+ if (!session)
44
+ return c.json({ error: "session_not_found" }, 404);
45
+ const persons = await resolvePersonMap(c.var.ctx.storage, [session]);
46
+ return c.json({ session, persons });
47
+ });
36
48
  app.get("/sessions/:id", async (c) => {
37
49
  const id = c.req.param("id");
38
50
  const s = await c.var.ctx.storage.getSession(id);
@@ -51,7 +63,15 @@ export function sessionsRoutes(cfg) {
51
63
  app.get("/sessions", async (c) => {
52
64
  const limit = clampLimit(c, cfg.defaultListLimit, cfg.maxListLimit);
53
65
  const channel = c.req.query("channel") || undefined;
54
- 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
+ });
55
75
  const persons = await resolvePersonMap(c.var.ctx.storage, sessions);
56
76
  return c.json({ sessions, persons });
57
77
  });
package/dist/schemas.d.ts CHANGED
@@ -15,6 +15,28 @@ export declare const sessionInitSchema: z.ZodObject<{
15
15
  channel: z.ZodOptional<z.ZodString>;
16
16
  participants: z.ZodOptional<z.ZodArray<z.ZodString>>;
17
17
  viewable_by: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
+ status: z.ZodOptional<z.ZodEnum<{
19
+ active: "active";
20
+ idle: "idle";
21
+ completed: "completed";
22
+ }>>;
23
+ summary: z.ZodOptional<z.ZodNullable<z.ZodString>>;
24
+ model: z.ZodOptional<z.ZodNullable<z.ZodString>>;
25
+ trigger_conversation_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
26
+ trigger_event_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
27
+ }, z.core.$strip>;
28
+ export declare const sessionUpdateSchema: z.ZodObject<{
29
+ title: z.ZodOptional<z.ZodNullable<z.ZodString>>;
30
+ channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
31
+ status: z.ZodOptional<z.ZodEnum<{
32
+ active: "active";
33
+ idle: "idle";
34
+ completed: "completed";
35
+ }>>;
36
+ summary: z.ZodOptional<z.ZodNullable<z.ZodString>>;
37
+ model: z.ZodOptional<z.ZodNullable<z.ZodString>>;
38
+ trigger_conversation_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
39
+ trigger_event_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
18
40
  }, z.core.$strip>;
19
41
  export declare const sessionEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
20
42
  type: z.ZodLiteral<"step">;
package/dist/schemas.js CHANGED
@@ -15,6 +15,20 @@ export const sessionInitSchema = z.object({
15
15
  channel: z.string().optional(),
16
16
  participants: z.array(z.string()).optional(),
17
17
  viewable_by: z.array(z.string()).optional(),
18
+ status: z.enum(["active", "idle", "completed"]).optional(),
19
+ summary: z.string().nullable().optional(),
20
+ model: z.string().nullable().optional(),
21
+ trigger_conversation_id: z.string().nullable().optional(),
22
+ trigger_event_id: z.string().nullable().optional(),
23
+ });
24
+ export const sessionUpdateSchema = z.object({
25
+ title: z.string().nullable().optional(),
26
+ channel: z.string().nullable().optional(),
27
+ status: z.enum(["active", "idle", "completed"]).optional(),
28
+ summary: z.string().nullable().optional(),
29
+ model: z.string().nullable().optional(),
30
+ trigger_conversation_id: z.string().nullable().optional(),
31
+ trigger_event_id: z.string().nullable().optional(),
18
32
  });
19
33
  const stepEventSchema = z.object({
20
34
  type: z.literal("step"),
package/dist/server.js CHANGED
@@ -52,7 +52,7 @@ export function createServer(opts) {
52
52
  ok: true,
53
53
  routes: {
54
54
  sessions: "POST/GET /v1/sessions",
55
- sessionById: "GET /v1/sessions/:id",
55
+ sessionById: "GET/PATCH /v1/sessions/:id",
56
56
  events: "POST /v1/sessions/:id/events",
57
57
  sessionEvents: "GET /v1/sessions/:id/events",
58
58
  search: "POST /v1/search",
package/dist/storage.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit, SessionUpdate } from "@hexis-ai/engram-sdk";
3
3
  /**
4
4
  * Storage adapter interface. Each implementation owns persistence for
5
5
  * a single workspace's sessions and persons. Multi-tenancy is the host's
@@ -18,6 +18,13 @@ export interface StorageAdapter {
18
18
  appendEvents(sessionId: string, events: SessionEvent[]): Promise<void>;
19
19
  /** Materialize a session (events folded into Session shape). */
20
20
  getSession(sessionId: string): Promise<Session | null>;
21
+ /**
22
+ * Patch session-level metadata (title / channel / status / summary /
23
+ * model / trigger_*). Each field updates only when present in `patch`
24
+ * — `undefined` leaves the column alone, `null` clears it. Returns
25
+ * the materialized session post-update, or `null` if unknown.
26
+ */
27
+ updateSession(sessionId: string, patch: SessionUpdate): Promise<Session | null>;
21
28
  /**
22
29
  * Raw event log for a session, ordered by `seq`. Unlike `getSession`,
23
30
  * this does not fold events — callers get per-event timestamps and the
@@ -26,10 +33,11 @@ export interface StorageAdapter {
26
33
  * events, which returns `[]`).
27
34
  */
28
35
  getSessionEvents(sessionId: string): Promise<SessionEvent[] | null>;
29
- /** List recent sessions. */
36
+ /** List recent sessions, ordered by `updated_at` desc. */
30
37
  listSessions(opts: {
31
38
  limit: number;
32
39
  channel?: string;
40
+ status?: "active" | "idle" | "completed";
33
41
  }): Promise<Session[]>;
34
42
  /**
35
43
  * Create a person with a freshly allocated id. The host (e.g. monet)
@@ -46,7 +54,12 @@ export interface StorageAdapter {
46
54
  getPerson(id: string): Promise<PersonInfo | null>;
47
55
  /** Batch fetch — used by response envelopes to inline `persons` maps. */
48
56
  getPersons(ids: string[]): Promise<PersonInfo[]>;
49
- /** 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
+ */
50
63
  listPersons(opts: {
51
64
  limit: number;
52
65
  q?: string;
@@ -102,5 +115,11 @@ export interface SessionRow {
102
115
  participants: string[];
103
116
  viewable_by: string[];
104
117
  createdAt: string;
118
+ updatedAt: string;
119
+ status?: "active" | "idle" | "completed";
120
+ summary?: string;
121
+ model?: string;
122
+ trigger_conversation_id?: string;
123
+ trigger_event_id?: string;
105
124
  }
106
125
  export declare function foldEvents(row: SessionRow, events: SessionEvent[], now: Date): Session;
package/dist/storage.js CHANGED
@@ -21,9 +21,20 @@ export function foldEvents(row, events, now) {
21
21
  return {
22
22
  id: row.id,
23
23
  ...(title ? { title } : {}),
24
+ ...(row.channel ? { channel: row.channel } : {}),
24
25
  steps,
25
26
  daysAgo,
26
27
  ...(participants.size > 0 ? { participants: [...participants] } : {}),
27
28
  ...(viewableSet.size > 0 ? { viewable_by: [...viewableSet] } : {}),
29
+ ...(row.status ? { status: row.status } : {}),
30
+ ...(row.summary !== undefined ? { summary: row.summary } : {}),
31
+ ...(row.model !== undefined ? { model: row.model } : {}),
32
+ ...(row.trigger_conversation_id !== undefined
33
+ ? { trigger_conversation_id: row.trigger_conversation_id }
34
+ : {}),
35
+ ...(row.trigger_event_id !== undefined
36
+ ? { trigger_event_id: row.trigger_event_id }
37
+ : {}),
38
+ updated_at: row.updatedAt,
28
39
  };
29
40
  }
package/openapi.json CHANGED
@@ -85,6 +85,54 @@
85
85
  "items": {
86
86
  "type": "string"
87
87
  }
88
+ },
89
+ "status": {
90
+ "type": "string",
91
+ "enum": [
92
+ "active",
93
+ "idle",
94
+ "completed"
95
+ ]
96
+ },
97
+ "summary": {
98
+ "anyOf": [
99
+ {
100
+ "type": "string"
101
+ },
102
+ {
103
+ "type": "null"
104
+ }
105
+ ]
106
+ },
107
+ "model": {
108
+ "anyOf": [
109
+ {
110
+ "type": "string"
111
+ },
112
+ {
113
+ "type": "null"
114
+ }
115
+ ]
116
+ },
117
+ "trigger_conversation_id": {
118
+ "anyOf": [
119
+ {
120
+ "type": "string"
121
+ },
122
+ {
123
+ "type": "null"
124
+ }
125
+ ]
126
+ },
127
+ "trigger_event_id": {
128
+ "anyOf": [
129
+ {
130
+ "type": "string"
131
+ },
132
+ {
133
+ "type": "null"
134
+ }
135
+ ]
88
136
  }
89
137
  },
90
138
  "additionalProperties": false
@@ -536,6 +584,47 @@
536
584
  "tags": [
537
585
  "Sessions"
538
586
  ]
587
+ },
588
+ "patch": {
589
+ "summary": "セッションのメタデータ(title / channel / status / summary / model / trigger_*)を部分更新する。",
590
+ "parameters": [
591
+ {
592
+ "name": "id",
593
+ "in": "path",
594
+ "required": true,
595
+ "schema": {
596
+ "type": "string"
597
+ },
598
+ "description": "セッション id。"
599
+ }
600
+ ],
601
+ "requestBody": {
602
+ "required": true,
603
+ "content": {
604
+ "application/json": {
605
+ "schema": {
606
+ "$ref": "#/components/schemas/SessionUpdate"
607
+ }
608
+ }
609
+ }
610
+ },
611
+ "responses": {
612
+ "200": {
613
+ "description": "更新後のセッションのエンベロープ"
614
+ },
615
+ "400": {
616
+ "description": "リクエストボディが不正"
617
+ },
618
+ "401": {
619
+ "description": "認証エラー"
620
+ },
621
+ "404": {
622
+ "description": "セッションが見つからない"
623
+ }
624
+ },
625
+ "tags": [
626
+ "Sessions"
627
+ ]
539
628
  }
540
629
  },
541
630
  "/v1/sessions/{id}/events": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexis-ai/engram-server",
3
- "version": "0.7.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.6.0",
53
+ "@hexis-ai/engram-sdk": "^0.8.0",
54
54
  "hono": "^4.6.0",
55
55
  "zod": "^4.0.0"
56
56
  },