@hexis-ai/engram-server 0.1.4 → 0.1.6
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-key-store.d.ts +24 -0
- package/dist/adapters/memory-key-store.js +108 -0
- package/dist/adapters/memory.d.ts +22 -1
- package/dist/adapters/memory.js +104 -2
- package/dist/adapters/postgres-key-store.d.ts +26 -0
- package/dist/adapters/postgres-key-store.js +143 -0
- package/dist/adapters/postgres.d.ts +23 -3
- package/dist/adapters/postgres.js +156 -36
- package/dist/admin.d.ts +21 -0
- package/dist/admin.js +99 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/key-store.d.ts +63 -0
- package/dist/key-store.js +17 -0
- package/dist/main.js +64 -36
- package/dist/migrations/0001-baseline.d.ts +2 -0
- package/dist/migrations/0001-baseline.js +72 -0
- package/dist/migrations/index.d.ts +12 -0
- package/dist/migrations/index.js +9 -0
- package/dist/migrator.d.ts +17 -0
- package/dist/migrator.js +37 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +92 -9
- package/dist/storage.d.ts +37 -4
- package/dist/storage.js +4 -1
- package/package.json +14 -5
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Migration {
|
|
2
|
+
name: string;
|
|
3
|
+
sql: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Schema migrations, applied in array order. Add a new file under
|
|
7
|
+
* `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
|
|
8
|
+
* here. Each migration's SQL must be idempotent (CREATE TABLE IF NOT
|
|
9
|
+
* EXISTS, ADD COLUMN IF NOT EXISTS, etc.) so a first apply on a DB that
|
|
10
|
+
* predates the migrator is a no-op.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MIGRATIONS: Migration[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as m0001 from "./0001-baseline";
|
|
2
|
+
/**
|
|
3
|
+
* Schema migrations, applied in array order. Add a new file under
|
|
4
|
+
* `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
|
|
5
|
+
* here. Each migration's SQL must be idempotent (CREATE TABLE IF NOT
|
|
6
|
+
* EXISTS, ADD COLUMN IF NOT EXISTS, etc.) so a first apply on a DB that
|
|
7
|
+
* predates the migrator is a no-op.
|
|
8
|
+
*/
|
|
9
|
+
export const MIGRATIONS = [{ name: m0001.name, sql: m0001.sql }];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SqlClient } from "./adapters/postgres";
|
|
2
|
+
import { type Migration } from "./migrations";
|
|
3
|
+
/**
|
|
4
|
+
* Apply pending schema migrations in order. Records applied migrations in
|
|
5
|
+
* `engram_schema_migrations` so subsequent boots skip them.
|
|
6
|
+
*
|
|
7
|
+
* Each migration's SQL must itself be idempotent (IF NOT EXISTS, etc.) —
|
|
8
|
+
* the tracking table protects against unnecessary re-runs but a partial
|
|
9
|
+
* failure between executing the SQL and inserting the tracking row would
|
|
10
|
+
* cause a replay on next boot, and we want that replay to be a no-op.
|
|
11
|
+
*
|
|
12
|
+
* Safe to call concurrently from multiple instances: the INSERT uses ON
|
|
13
|
+
* CONFLICT and the migration SQL itself is idempotent, so two boots
|
|
14
|
+
* applying the same migration in parallel just race to a consistent end
|
|
15
|
+
* state.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runMigrations(sql: SqlClient, migrations?: readonly Migration[]): Promise<void>;
|
package/dist/migrator.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MIGRATIONS } from "./migrations";
|
|
2
|
+
/**
|
|
3
|
+
* Apply pending schema migrations in order. Records applied migrations in
|
|
4
|
+
* `engram_schema_migrations` so subsequent boots skip them.
|
|
5
|
+
*
|
|
6
|
+
* Each migration's SQL must itself be idempotent (IF NOT EXISTS, etc.) —
|
|
7
|
+
* the tracking table protects against unnecessary re-runs but a partial
|
|
8
|
+
* failure between executing the SQL and inserting the tracking row would
|
|
9
|
+
* cause a replay on next boot, and we want that replay to be a no-op.
|
|
10
|
+
*
|
|
11
|
+
* Safe to call concurrently from multiple instances: the INSERT uses ON
|
|
12
|
+
* CONFLICT and the migration SQL itself is idempotent, so two boots
|
|
13
|
+
* applying the same migration in parallel just race to a consistent end
|
|
14
|
+
* state.
|
|
15
|
+
*/
|
|
16
|
+
export async function runMigrations(sql, migrations = MIGRATIONS) {
|
|
17
|
+
await sql.unsafe(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS engram_schema_migrations (
|
|
19
|
+
name TEXT PRIMARY KEY,
|
|
20
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
21
|
+
)
|
|
22
|
+
`);
|
|
23
|
+
const appliedRows = await sql `
|
|
24
|
+
SELECT name FROM engram_schema_migrations
|
|
25
|
+
`;
|
|
26
|
+
const applied = new Set(appliedRows.map((r) => r.name));
|
|
27
|
+
for (const m of migrations) {
|
|
28
|
+
if (applied.has(m.name))
|
|
29
|
+
continue;
|
|
30
|
+
await sql.unsafe(m.sql);
|
|
31
|
+
await sql `
|
|
32
|
+
INSERT INTO engram_schema_migrations (name)
|
|
33
|
+
VALUES (${m.name})
|
|
34
|
+
ON CONFLICT (name) DO NOTHING
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { StorageAdapter } from "./storage";
|
|
3
|
+
import { type AdminOptions } from "./admin";
|
|
3
4
|
/**
|
|
4
5
|
* Resolve an API key (raw `Authorization: Bearer <key>` token) into a
|
|
5
6
|
* workspace context. Throwing or returning null short-circuits to 401.
|
|
@@ -25,6 +26,11 @@ export interface CreateServerOptions {
|
|
|
25
26
|
*/
|
|
26
27
|
defaultListLimit?: number;
|
|
27
28
|
maxListLimit?: number;
|
|
29
|
+
/**
|
|
30
|
+
* When set, mounts the admin sub-router at `/admin/v1`. Admin auth is
|
|
31
|
+
* a separate platform token, never crossed with workspace API keys.
|
|
32
|
+
*/
|
|
33
|
+
admin?: AdminOptions;
|
|
28
34
|
}
|
|
29
35
|
interface Env {
|
|
30
36
|
Variables: {
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { buildIndex, search, } from "@hexis-ai/engram-core";
|
|
3
3
|
import { log, newRequestId } from "./logger";
|
|
4
|
+
import { createAdminRouter } from "./admin";
|
|
4
5
|
export function createServer(opts) {
|
|
5
6
|
const newId = opts.newSessionId ?? (() => crypto.randomUUID());
|
|
6
7
|
const defaultLimit = opts.defaultListLimit ?? 100;
|
|
@@ -36,8 +37,6 @@ export function createServer(opts) {
|
|
|
36
37
|
});
|
|
37
38
|
return c.json({ error: "internal_error" }, 500);
|
|
38
39
|
});
|
|
39
|
-
// Unauthenticated service info. Useful for browser sanity checks and
|
|
40
|
-
// health probes (Cloud Run, k8s, uptime monitors).
|
|
41
40
|
app.get("/", (c) => c.json({
|
|
42
41
|
service: "@hexis-ai/engram-server",
|
|
43
42
|
ok: true,
|
|
@@ -46,14 +45,16 @@ export function createServer(opts) {
|
|
|
46
45
|
sessionById: "GET /v1/sessions/:id",
|
|
47
46
|
events: "POST /v1/sessions/:id/events",
|
|
48
47
|
search: "POST /v1/search",
|
|
48
|
+
persons: "POST/GET /v1/persons",
|
|
49
|
+
personById: "GET/PUT/PATCH /v1/persons/:id",
|
|
50
|
+
personSessions: "GET /v1/persons/:id/sessions",
|
|
49
51
|
},
|
|
50
52
|
}));
|
|
51
53
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
54
|
+
if (opts.admin) {
|
|
55
|
+
app.route("/admin/v1", createAdminRouter(opts.admin));
|
|
56
|
+
}
|
|
52
57
|
app.use("/v1/*", async (c, next) => {
|
|
53
|
-
// Prefer X-Api-Key so callers can use the Authorization header for
|
|
54
|
-
// platform-level auth (Cloud Run IAM with an ID token, for example)
|
|
55
|
-
// without collision. Falls back to Authorization: Bearer <key> for
|
|
56
|
-
// existing clients.
|
|
57
58
|
const apiKey = c.req.header("x-api-key") ??
|
|
58
59
|
c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
59
60
|
if (!apiKey)
|
|
@@ -64,6 +65,10 @@ export function createServer(opts) {
|
|
|
64
65
|
c.set("ctx", ctx);
|
|
65
66
|
await next();
|
|
66
67
|
});
|
|
68
|
+
// Identity probe — echoes the workspace the caller's key resolves to.
|
|
69
|
+
// Used by clients (e.g. engram-web) to label which tenant they're viewing.
|
|
70
|
+
app.get("/v1/me", (c) => c.json({ workspaceId: c.var.ctx.workspaceId }));
|
|
71
|
+
// --- Sessions --------------------------------------------------------
|
|
67
72
|
app.post("/v1/sessions", async (c) => {
|
|
68
73
|
const body = (await c.req.json().catch(() => null));
|
|
69
74
|
if (body === null)
|
|
@@ -96,13 +101,15 @@ export function createServer(opts) {
|
|
|
96
101
|
const s = await c.var.ctx.storage.getSession(id);
|
|
97
102
|
if (!s)
|
|
98
103
|
return c.json({ error: "session_not_found" }, 404);
|
|
99
|
-
|
|
104
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, [s]);
|
|
105
|
+
return c.json({ session: s, persons });
|
|
100
106
|
});
|
|
101
107
|
app.get("/v1/sessions", async (c) => {
|
|
102
108
|
const limit = clampLimit(c, defaultLimit, maxLimit);
|
|
103
109
|
const channel = c.req.query("channel") || undefined;
|
|
104
110
|
const sessions = await c.var.ctx.storage.listSessions({ limit, channel });
|
|
105
|
-
|
|
111
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, sessions);
|
|
112
|
+
return c.json({ sessions, persons });
|
|
106
113
|
});
|
|
107
114
|
app.post("/v1/search", async (c) => {
|
|
108
115
|
const body = (await c.req.json().catch(() => null));
|
|
@@ -126,7 +133,61 @@ export function createServer(opts) {
|
|
|
126
133
|
...opts,
|
|
127
134
|
queryParticipants,
|
|
128
135
|
});
|
|
129
|
-
|
|
136
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, results.map((r) => r.session));
|
|
137
|
+
return c.json({ results, persons });
|
|
138
|
+
});
|
|
139
|
+
// --- Persons ---------------------------------------------------------
|
|
140
|
+
app.post("/v1/persons", async (c) => {
|
|
141
|
+
const body = (await c.req.json().catch(() => null));
|
|
142
|
+
if (body === null)
|
|
143
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
144
|
+
const p = await c.var.ctx.storage.createPerson(body);
|
|
145
|
+
return c.json(p, 201);
|
|
146
|
+
});
|
|
147
|
+
app.put("/v1/persons/:id", async (c) => {
|
|
148
|
+
const id = c.req.param("id");
|
|
149
|
+
const body = (await c.req.json().catch(() => null));
|
|
150
|
+
if (body === null)
|
|
151
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
152
|
+
const p = await c.var.ctx.storage.upsertPerson(id, body);
|
|
153
|
+
return c.json(p);
|
|
154
|
+
});
|
|
155
|
+
app.patch("/v1/persons/:id", async (c) => {
|
|
156
|
+
const id = c.req.param("id");
|
|
157
|
+
const body = (await c.req.json().catch(() => null));
|
|
158
|
+
if (body === null)
|
|
159
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
160
|
+
const p = await c.var.ctx.storage.updatePerson(id, body);
|
|
161
|
+
if (!p)
|
|
162
|
+
return c.json({ error: "person_not_found" }, 404);
|
|
163
|
+
return c.json(p);
|
|
164
|
+
});
|
|
165
|
+
app.get("/v1/persons/:id", async (c) => {
|
|
166
|
+
const id = c.req.param("id");
|
|
167
|
+
const p = await c.var.ctx.storage.getPerson(id);
|
|
168
|
+
if (!p)
|
|
169
|
+
return c.json({ error: "person_not_found" }, 404);
|
|
170
|
+
return c.json(p);
|
|
171
|
+
});
|
|
172
|
+
app.get("/v1/persons", async (c) => {
|
|
173
|
+
const limit = clampLimit(c, defaultLimit, maxLimit);
|
|
174
|
+
const q = c.req.query("q") || undefined;
|
|
175
|
+
const persons = await c.var.ctx.storage.listPersons({ limit, q });
|
|
176
|
+
return c.json({ persons });
|
|
177
|
+
});
|
|
178
|
+
app.get("/v1/persons/:id/sessions", async (c) => {
|
|
179
|
+
const id = c.req.param("id");
|
|
180
|
+
const limit = clampLimit(c, defaultLimit, maxLimit);
|
|
181
|
+
const channel = c.req.query("channel") || undefined;
|
|
182
|
+
const scopeRaw = c.req.query("scope");
|
|
183
|
+
const scope = scopeRaw === "viewable" ? "viewable" : "participant";
|
|
184
|
+
const sessions = await c.var.ctx.storage.sessionsForPerson(id, {
|
|
185
|
+
limit,
|
|
186
|
+
channel,
|
|
187
|
+
scope,
|
|
188
|
+
});
|
|
189
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, sessions);
|
|
190
|
+
return c.json({ sessions, persons });
|
|
130
191
|
});
|
|
131
192
|
return app;
|
|
132
193
|
}
|
|
@@ -139,3 +200,25 @@ function clampLimit(c, def, max) {
|
|
|
139
200
|
return def;
|
|
140
201
|
return Math.min(n, max);
|
|
141
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Build a deduped person_id → PersonInfo map covering every person id
|
|
205
|
+
* referenced by `sessions` (participants ∪ viewable_by). Returns {} if
|
|
206
|
+
* there are no session rows. Persons that exist as ids in sessions but
|
|
207
|
+
* have no engram_persons row are simply absent from the map.
|
|
208
|
+
*/
|
|
209
|
+
async function resolvePersonMap(storage, sessions) {
|
|
210
|
+
const ids = new Set();
|
|
211
|
+
for (const s of sessions) {
|
|
212
|
+
for (const id of s.participants ?? [])
|
|
213
|
+
ids.add(id);
|
|
214
|
+
for (const id of s.viewable_by ?? [])
|
|
215
|
+
ids.add(id);
|
|
216
|
+
}
|
|
217
|
+
if (ids.size === 0)
|
|
218
|
+
return {};
|
|
219
|
+
const list = await storage.getPersons([...ids]);
|
|
220
|
+
const map = {};
|
|
221
|
+
for (const p of list)
|
|
222
|
+
map[p.id] = p;
|
|
223
|
+
return map;
|
|
224
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { Session } from "@hexis-ai/engram-core";
|
|
2
|
-
import type { SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
|
|
2
|
+
import type { PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
|
|
3
3
|
/**
|
|
4
4
|
* Storage adapter interface. Each implementation owns persistence for
|
|
5
|
-
* a single workspace's sessions. Multi-tenancy is the host's
|
|
6
|
-
* server keys adapters by workspace id (derived from API key).
|
|
5
|
+
* a single workspace's sessions and persons. Multi-tenancy is the host's
|
|
6
|
+
* concern: the server keys adapters by workspace id (derived from API key).
|
|
7
7
|
*/
|
|
8
8
|
export interface StorageAdapter {
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Create a session row. Stores participants + viewable_by as the host
|
|
11
|
+
* passed them (person ids; identity resolution is upstream).
|
|
12
|
+
*/
|
|
10
13
|
createSession(init: SessionInit & {
|
|
11
14
|
id: string;
|
|
12
15
|
createdAt: string;
|
|
@@ -20,6 +23,35 @@ export interface StorageAdapter {
|
|
|
20
23
|
limit: number;
|
|
21
24
|
channel?: string;
|
|
22
25
|
}): Promise<Session[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Create a person with a freshly allocated id. The host (e.g. monet)
|
|
28
|
+
* then stores this id locally.
|
|
29
|
+
*/
|
|
30
|
+
createPerson(input: PersonCreate): Promise<PersonInfo>;
|
|
31
|
+
/**
|
|
32
|
+
* Upsert at an explicit id. Used by hosts that already have a stable id
|
|
33
|
+
* (backfill, fallback after an outage).
|
|
34
|
+
*/
|
|
35
|
+
upsertPerson(id: string, input: PersonCreate): Promise<PersonInfo>;
|
|
36
|
+
/** Update profile fields. Returns the updated record. */
|
|
37
|
+
updatePerson(id: string, patch: PersonUpdate): Promise<PersonInfo | null>;
|
|
38
|
+
getPerson(id: string): Promise<PersonInfo | null>;
|
|
39
|
+
/** Batch fetch — used by response envelopes to inline `persons` maps. */
|
|
40
|
+
getPersons(ids: string[]): Promise<PersonInfo[]>;
|
|
41
|
+
/** List or search persons in this workspace. */
|
|
42
|
+
listPersons(opts: {
|
|
43
|
+
limit: number;
|
|
44
|
+
q?: string;
|
|
45
|
+
}): Promise<PersonInfo[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Sessions where `personId` appears in `participants` (or `viewable_by`
|
|
48
|
+
* when `scope === "viewable"`). Ordered newest-first.
|
|
49
|
+
*/
|
|
50
|
+
sessionsForPerson(personId: string, opts: {
|
|
51
|
+
limit: number;
|
|
52
|
+
channel?: string;
|
|
53
|
+
scope?: "participant" | "viewable";
|
|
54
|
+
}): Promise<Session[]>;
|
|
23
55
|
}
|
|
24
56
|
/**
|
|
25
57
|
* Pure fold of an event log into the parts a Session needs. Used by adapters
|
|
@@ -30,6 +62,7 @@ export interface SessionRow {
|
|
|
30
62
|
title?: string;
|
|
31
63
|
channel?: string;
|
|
32
64
|
participants: string[];
|
|
65
|
+
viewable_by: string[];
|
|
33
66
|
createdAt: string;
|
|
34
67
|
}
|
|
35
68
|
export declare function foldEvents(row: SessionRow, events: SessionEvent[], now: Date): Session;
|
package/dist/storage.js
CHANGED
|
@@ -8,12 +8,14 @@ export function foldEvents(row, events, now) {
|
|
|
8
8
|
steps.push({ tool: ev.tool, resources: [...ev.resources] });
|
|
9
9
|
}
|
|
10
10
|
else if (ev.type === "participant") {
|
|
11
|
-
participants.add(ev.
|
|
11
|
+
participants.add(ev.personId);
|
|
12
12
|
}
|
|
13
13
|
else if (ev.type === "title") {
|
|
14
14
|
title = ev.title;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
// viewable_by always includes participants (the set is canonical here).
|
|
18
|
+
const viewableSet = new Set([...row.viewable_by, ...participants]);
|
|
17
19
|
const created = new Date(row.createdAt).getTime();
|
|
18
20
|
const daysAgo = Math.max(0, (now.getTime() - created) / 86_400_000);
|
|
19
21
|
return {
|
|
@@ -22,5 +24,6 @@ export function foldEvents(row, events, now) {
|
|
|
22
24
|
steps,
|
|
23
25
|
daysAgo,
|
|
24
26
|
...(participants.size > 0 ? { participants: [...participants] } : {}),
|
|
27
|
+
...(viewableSet.size > 0 ? { viewable_by: [...viewableSet] } : {}),
|
|
25
28
|
};
|
|
26
29
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexis-ai/engram-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Engram server: ingest agent session events, persist via a pluggable adapter, expose search.",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"engram",
|
|
7
|
+
"agents",
|
|
8
|
+
"search",
|
|
9
|
+
"hono",
|
|
10
|
+
"postgres",
|
|
11
|
+
"server"
|
|
12
|
+
],
|
|
6
13
|
"homepage": "https://github.com/hexis-ltd/engram#readme",
|
|
7
14
|
"repository": {
|
|
8
15
|
"type": "git",
|
|
@@ -41,15 +48,17 @@
|
|
|
41
48
|
"dev": "bun --hot src/dev.ts"
|
|
42
49
|
},
|
|
43
50
|
"dependencies": {
|
|
44
|
-
"@hexis-ai/engram-core": "^0.1.
|
|
45
|
-
"@hexis-ai/engram-sdk": "^0.1.
|
|
51
|
+
"@hexis-ai/engram-core": "^0.1.5",
|
|
52
|
+
"@hexis-ai/engram-sdk": "^0.1.5",
|
|
46
53
|
"hono": "^4.6.0"
|
|
47
54
|
},
|
|
48
55
|
"peerDependencies": {
|
|
49
56
|
"postgres": "^3.4.0"
|
|
50
57
|
},
|
|
51
58
|
"peerDependenciesMeta": {
|
|
52
|
-
"postgres": {
|
|
59
|
+
"postgres": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
53
62
|
},
|
|
54
63
|
"devDependencies": {
|
|
55
64
|
"postgres": "^3.4.0"
|