@hexis-ai/engram-server 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/memory.d.ts +2 -0
- package/dist/adapters/memory.js +58 -11
- package/dist/adapters/postgres.d.ts +2 -0
- package/dist/adapters/postgres.js +102 -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/aliases.d.ts +24 -0
- package/dist/routes/aliases.js +41 -0
- package/dist/routes/sessions.js +9 -1
- package/dist/server.js +3 -0
- package/dist/storage.d.ts +19 -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;
|
|
@@ -48,6 +49,7 @@ export declare class InMemoryAdapter implements StorageAdapter {
|
|
|
48
49
|
name: string;
|
|
49
50
|
} & AliasUpsert): Promise<AliasInfo | null>;
|
|
50
51
|
listAliases(personId: string): Promise<AliasInfo[]>;
|
|
52
|
+
findAliasesByName(name: string): Promise<AliasInfo[]>;
|
|
51
53
|
upsertIdentity(ref: string, input: IdentityUpsert): Promise<IdentityInfo | null>;
|
|
52
54
|
getIdentityByRef(ref: string): Promise<IdentityInfo | null>;
|
|
53
55
|
listIdentitiesByPerson(personId: string): Promise<IdentityInfo[]>;
|
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);
|
|
@@ -251,12 +287,22 @@ export class InMemoryAdapter {
|
|
|
251
287
|
matches.sort((a, b) => b.last_used.localeCompare(a.last_used));
|
|
252
288
|
return matches;
|
|
253
289
|
}
|
|
290
|
+
async findAliasesByName(name) {
|
|
291
|
+
const lower = name.toLowerCase();
|
|
292
|
+
const matches = [...this.aliases.values()].filter((a) => a.name.toLowerCase() === lower);
|
|
293
|
+
matches.sort((a, b) => b.last_used.localeCompare(a.last_used));
|
|
294
|
+
return matches;
|
|
295
|
+
}
|
|
254
296
|
// --- Identities ---------------------------------------------------
|
|
255
297
|
async upsertIdentity(ref, input) {
|
|
256
298
|
if (!this.persons.has(input.person_id))
|
|
257
299
|
return null;
|
|
258
300
|
const now = new Date().toISOString();
|
|
259
301
|
const existing = this.identities.get(ref);
|
|
302
|
+
// COALESCE-on-conflict for the soft-overlap fields; ref-level
|
|
303
|
+
// mapping (person_id, service, external_id, linked_at) always
|
|
304
|
+
// takes the latest input. unlinked_at uses undefined=no-change
|
|
305
|
+
// semantics so callers can re-link without explicitly clearing.
|
|
260
306
|
const next = {
|
|
261
307
|
ref,
|
|
262
308
|
person_id: input.person_id,
|
|
@@ -266,11 +312,12 @@ export class InMemoryAdapter {
|
|
|
266
312
|
? input.display_name
|
|
267
313
|
: existing?.display_name ?? null,
|
|
268
314
|
source: input.source !== undefined ? input.source : existing?.source ?? null,
|
|
269
|
-
is_primary: input.is_primary
|
|
270
|
-
? input.is_primary
|
|
271
|
-
: existing?.is_primary ?? null,
|
|
315
|
+
is_primary: input.is_primary ?? existing?.is_primary ?? false,
|
|
272
316
|
picture: input.picture !== undefined ? input.picture : existing?.picture ?? null,
|
|
273
317
|
linked_at: input.linked_at,
|
|
318
|
+
unlinked_at: input.unlinked_at !== undefined
|
|
319
|
+
? input.unlinked_at
|
|
320
|
+
: existing?.unlinked_at ?? null,
|
|
274
321
|
created_at: existing?.created_at ?? now,
|
|
275
322
|
updated_at: now,
|
|
276
323
|
};
|
|
@@ -42,6 +42,7 @@ export declare class PostgresAdapter implements StorageAdapter {
|
|
|
42
42
|
listSessions(opts: {
|
|
43
43
|
limit: number;
|
|
44
44
|
channel?: string;
|
|
45
|
+
status?: "active" | "idle" | "completed";
|
|
45
46
|
}): Promise<Session[]>;
|
|
46
47
|
sessionsForPerson(personId: string, opts: {
|
|
47
48
|
limit: number;
|
|
@@ -61,6 +62,7 @@ export declare class PostgresAdapter implements StorageAdapter {
|
|
|
61
62
|
name: string;
|
|
62
63
|
} & AliasUpsert): Promise<AliasInfo | null>;
|
|
63
64
|
listAliases(personId: string): Promise<AliasInfo[]>;
|
|
65
|
+
findAliasesByName(name: string): Promise<AliasInfo[]>;
|
|
64
66
|
upsertIdentity(ref: string, input: IdentityUpsert): Promise<IdentityInfo | null>;
|
|
65
67
|
getIdentityByRef(ref: string): Promise<IdentityInfo | null>;
|
|
66
68
|
listIdentitiesByPerson(personId: string): Promise<IdentityInfo[]>;
|
|
@@ -43,7 +43,7 @@ export class PostgresAdapter {
|
|
|
43
43
|
await this.sql `
|
|
44
44
|
INSERT INTO engram_sessions (
|
|
45
45
|
workspace_id, id, title, channel, participants, viewable_by,
|
|
46
|
-
created_at, status, summary, model,
|
|
46
|
+
created_at, updated_at, status, summary, model,
|
|
47
47
|
trigger_conversation_id, trigger_event_id
|
|
48
48
|
)
|
|
49
49
|
VALUES (
|
|
@@ -54,6 +54,7 @@ export class PostgresAdapter {
|
|
|
54
54
|
${participants},
|
|
55
55
|
${viewableBy},
|
|
56
56
|
${init.createdAt},
|
|
57
|
+
${init.createdAt},
|
|
57
58
|
${init.status ?? "active"},
|
|
58
59
|
${init.summary ?? null},
|
|
59
60
|
${init.model ?? null},
|
|
@@ -95,11 +96,20 @@ export class PostgresAdapter {
|
|
|
95
96
|
`;
|
|
96
97
|
}
|
|
97
98
|
}
|
|
99
|
+
// Bump updated_at once per batch so list-by-recent-activity stays
|
|
100
|
+
// accurate. Using the latest event's `at` (not now()) keeps the
|
|
101
|
+
// semantics deterministic for back-dated batches.
|
|
102
|
+
const latest = events[events.length - 1];
|
|
103
|
+
await this.sql `
|
|
104
|
+
UPDATE engram_sessions
|
|
105
|
+
SET updated_at = GREATEST(updated_at, ${latest.at}::timestamptz)
|
|
106
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
107
|
+
`;
|
|
98
108
|
}
|
|
99
109
|
async getSession(sessionId) {
|
|
100
110
|
const rows = await this.sql `
|
|
101
111
|
SELECT id, title, channel, participants, viewable_by, created_at,
|
|
102
|
-
status, summary, model,
|
|
112
|
+
updated_at, status, summary, model,
|
|
103
113
|
trigger_conversation_id, trigger_event_id
|
|
104
114
|
FROM engram_sessions
|
|
105
115
|
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
@@ -137,7 +147,8 @@ export class PostgresAdapter {
|
|
|
137
147
|
ELSE trigger_conversation_id END,
|
|
138
148
|
trigger_event_id = CASE WHEN ${teIdProvided}
|
|
139
149
|
THEN ${patch.trigger_event_id ?? null}
|
|
140
|
-
ELSE trigger_event_id END
|
|
150
|
+
ELSE trigger_event_id END,
|
|
151
|
+
updated_at = now()
|
|
141
152
|
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
142
153
|
RETURNING id
|
|
143
154
|
`;
|
|
@@ -162,11 +173,13 @@ export class PostgresAdapter {
|
|
|
162
173
|
}
|
|
163
174
|
async listSessions(opts) {
|
|
164
175
|
const channelFilter = opts.channel ?? null;
|
|
176
|
+
const statusFilter = opts.status ?? null;
|
|
165
177
|
const rows = await this.sql `
|
|
166
178
|
SELECT id FROM engram_sessions
|
|
167
179
|
WHERE workspace_id = ${this.workspaceId}
|
|
168
180
|
AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
|
|
169
|
-
|
|
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
|
|
@@ -323,6 +367,19 @@ export class PostgresAdapter {
|
|
|
323
367
|
`;
|
|
324
368
|
return rows.map(toAliasInfo);
|
|
325
369
|
}
|
|
370
|
+
async findAliasesByName(name) {
|
|
371
|
+
// The `name_lower` column is a STORED generated lower(name) backed
|
|
372
|
+
// by `idx_engram_aliases_name_lower (workspace_id, name_lower)`, so
|
|
373
|
+
// this lookup is index-only regardless of casing in the input.
|
|
374
|
+
const rows = await this.sql `
|
|
375
|
+
SELECT person_id, name, caller, usage_count, last_used, created_at, updated_at
|
|
376
|
+
FROM engram_aliases
|
|
377
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
378
|
+
AND name_lower = ${name.toLowerCase()}
|
|
379
|
+
ORDER BY last_used DESC
|
|
380
|
+
`;
|
|
381
|
+
return rows.map(toAliasInfo);
|
|
382
|
+
}
|
|
326
383
|
// --- Identities ---------------------------------------------------
|
|
327
384
|
async upsertIdentity(ref, input) {
|
|
328
385
|
// Pre-check rather than rely on the FK so unknown persons return
|
|
@@ -334,17 +391,21 @@ export class PostgresAdapter {
|
|
|
334
391
|
`;
|
|
335
392
|
if (personExists.length === 0)
|
|
336
393
|
return null;
|
|
394
|
+
// unlinked_at semantics: undefined = leave alone, null = clear,
|
|
395
|
+
// value = set. matches the patch contract for the other fields.
|
|
396
|
+
const unlinkedProvided = input.unlinked_at !== undefined;
|
|
337
397
|
const rows = await this.sql `
|
|
338
398
|
INSERT INTO engram_identities (
|
|
339
399
|
workspace_id, ref, person_id, service, external_id,
|
|
340
|
-
display_name, source, is_primary, picture, linked_at
|
|
400
|
+
display_name, source, is_primary, picture, linked_at, unlinked_at
|
|
341
401
|
)
|
|
342
402
|
VALUES (
|
|
343
403
|
${this.workspaceId}, ${ref}, ${input.person_id},
|
|
344
404
|
${input.service}, ${input.external_id},
|
|
345
405
|
${input.display_name ?? null}, ${input.source ?? null},
|
|
346
|
-
${input.is_primary ??
|
|
347
|
-
${input.linked_at}
|
|
406
|
+
${input.is_primary ?? false}, ${input.picture ?? null},
|
|
407
|
+
${input.linked_at},
|
|
408
|
+
${input.unlinked_at ?? null}
|
|
348
409
|
)
|
|
349
410
|
ON CONFLICT (workspace_id, ref) DO UPDATE SET
|
|
350
411
|
person_id = EXCLUDED.person_id,
|
|
@@ -352,19 +413,24 @@ export class PostgresAdapter {
|
|
|
352
413
|
external_id = EXCLUDED.external_id,
|
|
353
414
|
display_name = COALESCE(EXCLUDED.display_name, engram_identities.display_name),
|
|
354
415
|
source = COALESCE(EXCLUDED.source, engram_identities.source),
|
|
355
|
-
is_primary =
|
|
416
|
+
is_primary = EXCLUDED.is_primary,
|
|
356
417
|
picture = COALESCE(EXCLUDED.picture, engram_identities.picture),
|
|
357
418
|
linked_at = EXCLUDED.linked_at,
|
|
419
|
+
unlinked_at = CASE WHEN ${unlinkedProvided}
|
|
420
|
+
THEN ${input.unlinked_at ?? null}
|
|
421
|
+
ELSE engram_identities.unlinked_at END,
|
|
358
422
|
updated_at = now()
|
|
359
423
|
RETURNING ref, person_id, service, external_id, display_name, source,
|
|
360
|
-
is_primary, picture, linked_at,
|
|
424
|
+
is_primary, picture, linked_at, unlinked_at,
|
|
425
|
+
created_at, updated_at
|
|
361
426
|
`;
|
|
362
427
|
return toIdentityInfo(rows[0]);
|
|
363
428
|
}
|
|
364
429
|
async getIdentityByRef(ref) {
|
|
365
430
|
const rows = await this.sql `
|
|
366
431
|
SELECT ref, person_id, service, external_id, display_name, source,
|
|
367
|
-
is_primary, picture, linked_at,
|
|
432
|
+
is_primary, picture, linked_at, unlinked_at,
|
|
433
|
+
created_at, updated_at
|
|
368
434
|
FROM engram_identities
|
|
369
435
|
WHERE workspace_id = ${this.workspaceId} AND ref = ${ref}
|
|
370
436
|
LIMIT 1
|
|
@@ -376,7 +442,8 @@ export class PostgresAdapter {
|
|
|
376
442
|
async listIdentitiesByPerson(personId) {
|
|
377
443
|
const rows = await this.sql `
|
|
378
444
|
SELECT ref, person_id, service, external_id, display_name, source,
|
|
379
|
-
is_primary, picture, linked_at,
|
|
445
|
+
is_primary, picture, linked_at, unlinked_at,
|
|
446
|
+
created_at, updated_at
|
|
380
447
|
FROM engram_identities
|
|
381
448
|
WHERE workspace_id = ${this.workspaceId} AND person_id = ${personId}
|
|
382
449
|
ORDER BY linked_at DESC
|
|
@@ -389,20 +456,23 @@ function toPersonInfo(r) {
|
|
|
389
456
|
return {
|
|
390
457
|
id: r.id,
|
|
391
458
|
display_name: r.display_name,
|
|
459
|
+
role: r.role,
|
|
460
|
+
team: r.team,
|
|
461
|
+
source: r.source,
|
|
392
462
|
created_at: toIso(r.created_at),
|
|
393
463
|
updated_at: toIso(r.updated_at),
|
|
394
464
|
};
|
|
395
465
|
}
|
|
396
466
|
function toSessionRow(r) {
|
|
467
|
+
const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
|
|
397
468
|
return {
|
|
398
469
|
id: r.id,
|
|
399
470
|
...(r.title ? { title: r.title } : {}),
|
|
400
471
|
...(r.channel ? { channel: r.channel } : {}),
|
|
401
472
|
participants: r.participants,
|
|
402
473
|
viewable_by: r.viewable_by,
|
|
403
|
-
createdAt:
|
|
404
|
-
|
|
405
|
-
: r.created_at.toISOString(),
|
|
474
|
+
createdAt: toIso(r.created_at),
|
|
475
|
+
updatedAt: toIso(r.updated_at),
|
|
406
476
|
...(r.status === "active" || r.status === "idle" || r.status === "completed"
|
|
407
477
|
? { status: r.status }
|
|
408
478
|
: {}),
|
|
@@ -435,9 +505,7 @@ function toAliasInfo(r) {
|
|
|
435
505
|
}
|
|
436
506
|
function toIdentityInfo(r) {
|
|
437
507
|
const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
|
|
438
|
-
|
|
439
|
-
? r.linked_at.slice(0, 10)
|
|
440
|
-
: r.linked_at.toISOString().slice(0, 10);
|
|
508
|
+
// linked_at is now TIMESTAMPTZ (post-migration 0004). Emit full ISO.
|
|
441
509
|
return {
|
|
442
510
|
ref: r.ref,
|
|
443
511
|
person_id: r.person_id,
|
|
@@ -447,7 +515,8 @@ function toIdentityInfo(r) {
|
|
|
447
515
|
source: r.source,
|
|
448
516
|
is_primary: r.is_primary,
|
|
449
517
|
picture: r.picture,
|
|
450
|
-
linked_at:
|
|
518
|
+
linked_at: toIso(r.linked_at),
|
|
519
|
+
unlinked_at: r.unlinked_at === null ? null : toIso(r.unlinked_at),
|
|
451
520
|
created_at: toIso(r.created_at),
|
|
452
521
|
updated_at: toIso(r.updated_at),
|
|
453
522
|
};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const name = "0005-session-updated-at";
|
|
2
|
+
export declare const sql = "\n-- Session-level updated_at, so listSessions can sort by \"most recent\n-- activity\" (matching monet's conversations.updated_at semantics) and\n-- callers can filter \"active in the last hour\" without re-folding the\n-- event log. Maintained by appendEvents + updateSession at write time.\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;\nUPDATE engram_sessions SET updated_at = created_at WHERE updated_at IS NULL;\nALTER TABLE engram_sessions ALTER COLUMN updated_at SET DEFAULT NOW();\nALTER TABLE engram_sessions ALTER COLUMN updated_at SET NOT NULL;\n\n-- Index supporting the most common list-by-recent-activity query and\n-- its status-filtered variant (status filter is cheap once the date\n-- range scan is narrow).\nCREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_updated\n ON engram_sessions (workspace_id, updated_at DESC);\n";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const name = "0005-session-updated-at";
|
|
2
|
+
export const sql = `
|
|
3
|
+
-- Session-level updated_at, so listSessions can sort by "most recent
|
|
4
|
+
-- activity" (matching monet's conversations.updated_at semantics) and
|
|
5
|
+
-- callers can filter "active in the last hour" without re-folding the
|
|
6
|
+
-- event log. Maintained by appendEvents + updateSession at write time.
|
|
7
|
+
ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
|
|
8
|
+
UPDATE engram_sessions SET updated_at = created_at WHERE updated_at IS NULL;
|
|
9
|
+
ALTER TABLE engram_sessions ALTER COLUMN updated_at SET DEFAULT NOW();
|
|
10
|
+
ALTER TABLE engram_sessions ALTER COLUMN updated_at SET NOT NULL;
|
|
11
|
+
|
|
12
|
+
-- Index supporting the most common list-by-recent-activity query and
|
|
13
|
+
-- its status-filtered variant (status filter is cheap once the date
|
|
14
|
+
-- range scan is narrow).
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_updated
|
|
16
|
+
ON engram_sessions (workspace_id, updated_at DESC);
|
|
17
|
+
`;
|
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
|
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../context";
|
|
3
|
+
import { type RouteConfig } from "./helpers";
|
|
4
|
+
/**
|
|
5
|
+
* Workspace-wide alias lookup. Mount under `/v1`:
|
|
6
|
+
* GET /v1/aliases?name=<name>
|
|
7
|
+
*
|
|
8
|
+
* The personal-scoped endpoints (`/v1/persons/:id/aliases`) cover
|
|
9
|
+
* "what aliases does this person have?". This top-level route covers
|
|
10
|
+
* the inverse — "which person(s) does this name resolve to?" — which
|
|
11
|
+
* hosts use to ground a free-text "who is X?" against the alias
|
|
12
|
+
* registry without pre-knowing the owning person.
|
|
13
|
+
*
|
|
14
|
+
* The match is case-insensitive (the alias table is keyed by
|
|
15
|
+
* `lower(name)`). Multiple persons in the same workspace can share an
|
|
16
|
+
* alias, so the response is an array ordered by `last_used` desc; the
|
|
17
|
+
* caller picks the most recently active match (or applies its own
|
|
18
|
+
* tie-breaker like caller/usage_count).
|
|
19
|
+
*
|
|
20
|
+
* Returns 400 when `?name=` is missing — alias lookups have no useful
|
|
21
|
+
* "list all aliases" semantics, so dropping the query param is treated
|
|
22
|
+
* as a request error rather than a full scan.
|
|
23
|
+
*/
|
|
24
|
+
export declare function aliasesRoutes(_cfg: RouteConfig): Hono<Env>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { resolvePersonMap } from "./helpers";
|
|
3
|
+
/**
|
|
4
|
+
* Workspace-wide alias lookup. Mount under `/v1`:
|
|
5
|
+
* GET /v1/aliases?name=<name>
|
|
6
|
+
*
|
|
7
|
+
* The personal-scoped endpoints (`/v1/persons/:id/aliases`) cover
|
|
8
|
+
* "what aliases does this person have?". This top-level route covers
|
|
9
|
+
* the inverse — "which person(s) does this name resolve to?" — which
|
|
10
|
+
* hosts use to ground a free-text "who is X?" against the alias
|
|
11
|
+
* registry without pre-knowing the owning person.
|
|
12
|
+
*
|
|
13
|
+
* The match is case-insensitive (the alias table is keyed by
|
|
14
|
+
* `lower(name)`). Multiple persons in the same workspace can share an
|
|
15
|
+
* alias, so the response is an array ordered by `last_used` desc; the
|
|
16
|
+
* caller picks the most recently active match (or applies its own
|
|
17
|
+
* tie-breaker like caller/usage_count).
|
|
18
|
+
*
|
|
19
|
+
* Returns 400 when `?name=` is missing — alias lookups have no useful
|
|
20
|
+
* "list all aliases" semantics, so dropping the query param is treated
|
|
21
|
+
* as a request error rather than a full scan.
|
|
22
|
+
*/
|
|
23
|
+
export function aliasesRoutes(_cfg) {
|
|
24
|
+
const app = new Hono();
|
|
25
|
+
app.get("/aliases", async (c) => {
|
|
26
|
+
const name = c.req.query("name");
|
|
27
|
+
if (!name) {
|
|
28
|
+
return c.json({
|
|
29
|
+
error: "name_required",
|
|
30
|
+
message: "GET /v1/aliases requires a ?name= query parameter",
|
|
31
|
+
}, 400);
|
|
32
|
+
}
|
|
33
|
+
const aliases = await c.var.ctx.storage.findAliasesByName(name);
|
|
34
|
+
// Inline the persons map so the caller can render a "candidate
|
|
35
|
+
// picker" UI without a second round-trip per match. Same envelope
|
|
36
|
+
// shape as the session list endpoints.
|
|
37
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, aliases.map((a) => ({ participants: [a.person_id], viewable_by: [] })));
|
|
38
|
+
return c.json({ aliases, persons });
|
|
39
|
+
});
|
|
40
|
+
return app;
|
|
41
|
+
}
|
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/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { log, newRequestId } from "./logger";
|
|
3
3
|
import { createAdminRouter } from "./admin";
|
|
4
|
+
import { aliasesRoutes } from "./routes/aliases";
|
|
4
5
|
import { sessionsRoutes } from "./routes/sessions";
|
|
5
6
|
import { personsRoutes } from "./routes/persons";
|
|
6
7
|
import { identitiesRoutes } from "./routes/identities";
|
|
@@ -61,6 +62,7 @@ export function createServer(opts) {
|
|
|
61
62
|
personSessions: "GET /v1/persons/:id/sessions",
|
|
62
63
|
personAliases: "GET /v1/persons/:id/aliases",
|
|
63
64
|
upsertAlias: "PUT /v1/persons/:id/aliases/:name",
|
|
65
|
+
findAliasesByName: "GET /v1/aliases?name=…",
|
|
64
66
|
identityByRef: "GET/PUT /v1/identities/:ref",
|
|
65
67
|
personIdentities: "GET /v1/persons/:id/identities",
|
|
66
68
|
},
|
|
@@ -88,6 +90,7 @@ export function createServer(opts) {
|
|
|
88
90
|
app.route("/v1", sessionsRoutes(cfg));
|
|
89
91
|
app.route("/v1", personsRoutes(cfg));
|
|
90
92
|
app.route("/v1", identitiesRoutes(cfg));
|
|
93
|
+
app.route("/v1", aliasesRoutes(cfg));
|
|
91
94
|
app.route("/v1", searchRoutes(cfg));
|
|
92
95
|
return app;
|
|
93
96
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -33,10 +33,11 @@ export interface StorageAdapter {
|
|
|
33
33
|
* events, which returns `[]`).
|
|
34
34
|
*/
|
|
35
35
|
getSessionEvents(sessionId: string): Promise<SessionEvent[] | null>;
|
|
36
|
-
/** List recent sessions. */
|
|
36
|
+
/** List recent sessions, ordered by `updated_at` desc. */
|
|
37
37
|
listSessions(opts: {
|
|
38
38
|
limit: number;
|
|
39
39
|
channel?: string;
|
|
40
|
+
status?: "active" | "idle" | "completed";
|
|
40
41
|
}): Promise<Session[]>;
|
|
41
42
|
/**
|
|
42
43
|
* Create a person with a freshly allocated id. The host (e.g. monet)
|
|
@@ -53,7 +54,12 @@ export interface StorageAdapter {
|
|
|
53
54
|
getPerson(id: string): Promise<PersonInfo | null>;
|
|
54
55
|
/** Batch fetch — used by response envelopes to inline `persons` maps. */
|
|
55
56
|
getPersons(ids: string[]): Promise<PersonInfo[]>;
|
|
56
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* List or search persons in this workspace. The free-text query
|
|
59
|
+
* matches across id / display_name / role / team / linked identity
|
|
60
|
+
* display_names — so a search for "design" finds people with that
|
|
61
|
+
* role even if their display_name doesn't contain it.
|
|
62
|
+
*/
|
|
57
63
|
listPersons(opts: {
|
|
58
64
|
limit: number;
|
|
59
65
|
q?: string;
|
|
@@ -84,6 +90,16 @@ export interface StorageAdapter {
|
|
|
84
90
|
} & AliasUpsert): Promise<AliasInfo | null>;
|
|
85
91
|
/** A person's aliases, ordered newest-used-first. */
|
|
86
92
|
listAliases(personId: string): Promise<AliasInfo[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Workspace-wide lookup by alias name (case-insensitive). Returns
|
|
95
|
+
* every row whose `lower(name)` matches `lower(name)` across all
|
|
96
|
+
* persons in this workspace. Ordered by `last_used` desc — the
|
|
97
|
+
* caller can pick the most recently active match without re-sorting.
|
|
98
|
+
*
|
|
99
|
+
* Hosts use this to resolve a free-text "who is X?" query into a
|
|
100
|
+
* person id without pre-knowing which person owns the alias.
|
|
101
|
+
*/
|
|
102
|
+
findAliasesByName(name: string): Promise<AliasInfo[]>;
|
|
87
103
|
/**
|
|
88
104
|
* Upsert by `ref` (e.g. `slack:U12345`). Idempotent: writing the same
|
|
89
105
|
* ref multiple times converges. If the ref points to a different
|
|
@@ -109,6 +125,7 @@ export interface SessionRow {
|
|
|
109
125
|
participants: string[];
|
|
110
126
|
viewable_by: string[];
|
|
111
127
|
createdAt: string;
|
|
128
|
+
updatedAt: string;
|
|
112
129
|
status?: "active" | "idle" | "completed";
|
|
113
130
|
summary?: string;
|
|
114
131
|
model?: string;
|
package/dist/storage.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexis-ai/engram-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Engram server: ingest agent session events, persist via a pluggable adapter, expose search.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"engram",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@hexis-ai/engram-core": "^0.1.5",
|
|
53
|
-
"@hexis-ai/engram-sdk": "^0.
|
|
53
|
+
"@hexis-ai/engram-sdk": "^0.9.0",
|
|
54
54
|
"hono": "^4.6.0",
|
|
55
55
|
"zod": "^4.0.0"
|
|
56
56
|
},
|