@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.
- package/dist/adapters/memory.d.ts +1 -0
- package/dist/adapters/memory.js +52 -11
- package/dist/adapters/postgres.d.ts +1 -0
- package/dist/adapters/postgres.js +89 -33
- package/dist/migrations/0005-session-updated-at.d.ts +2 -0
- package/dist/migrations/0005-session-updated-at.js +17 -0
- package/dist/migrations/index.js +2 -0
- package/dist/routes/sessions.js +9 -1
- package/dist/storage.d.ts +9 -2
- package/dist/storage.js +1 -0
- package/package.json +2 -2
|
@@ -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;
|
package/dist/adapters/memory.js
CHANGED
|
@@ -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
|
-
|
|
154
|
+
updatedAt: stored.row.updatedAt,
|
|
144
155
|
});
|
|
145
156
|
}
|
|
146
|
-
all.sort((a, b) => b.
|
|
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) =>
|
|
220
|
-
(p.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
222
|
-
|
|
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,
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 ??
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
404
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
`;
|
package/dist/migrations/index.js
CHANGED
|
@@ -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
|
];
|
package/dist/routes/sessions.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexis-ai/engram-server",
|
|
3
|
-
"version": "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.
|
|
53
|
+
"@hexis-ai/engram-sdk": "^0.8.0",
|
|
54
54
|
"hono": "^4.6.0",
|
|
55
55
|
"zod": "^4.0.0"
|
|
56
56
|
},
|