@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.
- package/dist/adapters/memory.d.ts +3 -1
- package/dist/adapters/memory.js +112 -11
- package/dist/adapters/postgres.d.ts +3 -1
- package/dist/adapters/postgres.js +154 -40
- package/dist/migrations/0004-schema-completion.d.ts +2 -0
- package/dist/migrations/0004-schema-completion.js +53 -0
- 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 +4 -0
- package/dist/openapi.js +11 -0
- package/dist/routes/sessions.d.ts +6 -5
- package/dist/routes/sessions.js +27 -7
- package/dist/schemas.d.ts +22 -0
- package/dist/schemas.js +14 -0
- package/dist/server.js +1 -1
- package/dist/storage.d.ts +22 -3
- package/dist/storage.js +11 -0
- package/openapi.json +89 -0
- package/package.json +2 -2
|
@@ -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;
|
package/dist/adapters/memory.js
CHANGED
|
@@ -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
|
-
|
|
154
|
+
updatedAt: stored.row.updatedAt,
|
|
84
155
|
});
|
|
85
156
|
}
|
|
86
|
-
all.sort((a, b) => b.
|
|
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) =>
|
|
160
|
-
(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
|
+
})
|
|
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
|
|
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 (
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
187
|
-
|
|
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,
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 ??
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
+
`;
|
package/dist/migrations/index.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* GET
|
|
10
|
-
* GET
|
|
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>;
|
package/dist/routes/sessions.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* GET
|
|
10
|
-
* GET
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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
|
},
|