@hexis-ai/engram-server 0.1.2 → 0.1.5
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 +23 -0
- package/dist/adapters/postgres-key-store.js +161 -0
- package/dist/adapters/postgres.d.ts +21 -1
- package/dist/adapters/postgres.js +176 -4
- 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/logger.d.ts +26 -0
- package/dist/logger.js +33 -0
- package/dist/main.js +74 -36
- package/dist/server.d.ts +7 -0
- package/dist/server.js +127 -9
- package/dist/storage.d.ts +37 -4
- package/dist/storage.js +4 -1
- package/package.json +3 -3
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,10 +26,16 @@ 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: {
|
|
31
37
|
ctx: WorkspaceContext;
|
|
38
|
+
request_id: string;
|
|
32
39
|
};
|
|
33
40
|
}
|
|
34
41
|
export declare function createServer(opts: CreateServerOptions): Hono<Env>;
|
package/dist/server.js
CHANGED
|
@@ -1,12 +1,42 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { buildIndex, search, } from "@hexis-ai/engram-core";
|
|
3
|
+
import { log, newRequestId } from "./logger";
|
|
4
|
+
import { createAdminRouter } from "./admin";
|
|
3
5
|
export function createServer(opts) {
|
|
4
6
|
const newId = opts.newSessionId ?? (() => crypto.randomUUID());
|
|
5
7
|
const defaultLimit = opts.defaultListLimit ?? 100;
|
|
6
8
|
const maxLimit = opts.maxListLimit ?? 500;
|
|
7
9
|
const app = new Hono();
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
+
// Request id + access log middleware. Runs for every route.
|
|
11
|
+
app.use("*", async (c, next) => {
|
|
12
|
+
const rid = c.req.header("x-request-id") ?? newRequestId();
|
|
13
|
+
c.set("request_id", rid);
|
|
14
|
+
c.header("x-request-id", rid);
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
let status = 500;
|
|
17
|
+
try {
|
|
18
|
+
await next();
|
|
19
|
+
status = c.res.status;
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
log.info("request", {
|
|
23
|
+
request_id: rid,
|
|
24
|
+
method: c.req.method,
|
|
25
|
+
path: c.req.path,
|
|
26
|
+
status,
|
|
27
|
+
duration_ms: Date.now() - start,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
app.onError((err, c) => {
|
|
32
|
+
log.error("unhandled", {
|
|
33
|
+
request_id: c.var.request_id,
|
|
34
|
+
path: c.req.path,
|
|
35
|
+
error: err instanceof Error ? err.message : String(err),
|
|
36
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
37
|
+
});
|
|
38
|
+
return c.json({ error: "internal_error" }, 500);
|
|
39
|
+
});
|
|
10
40
|
app.get("/", (c) => c.json({
|
|
11
41
|
service: "@hexis-ai/engram-server",
|
|
12
42
|
ok: true,
|
|
@@ -15,20 +45,30 @@ export function createServer(opts) {
|
|
|
15
45
|
sessionById: "GET /v1/sessions/:id",
|
|
16
46
|
events: "POST /v1/sessions/:id/events",
|
|
17
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",
|
|
18
51
|
},
|
|
19
52
|
}));
|
|
20
53
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
54
|
+
if (opts.admin) {
|
|
55
|
+
app.route("/admin/v1", createAdminRouter(opts.admin));
|
|
56
|
+
}
|
|
21
57
|
app.use("/v1/*", async (c, next) => {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
if (!
|
|
58
|
+
const apiKey = c.req.header("x-api-key") ??
|
|
59
|
+
c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
60
|
+
if (!apiKey)
|
|
25
61
|
return c.json({ error: "unauthorized" }, 401);
|
|
26
|
-
const ctx = await opts.auth(
|
|
62
|
+
const ctx = await opts.auth(apiKey);
|
|
27
63
|
if (!ctx)
|
|
28
64
|
return c.json({ error: "unauthorized" }, 401);
|
|
29
65
|
c.set("ctx", ctx);
|
|
30
66
|
await next();
|
|
31
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 --------------------------------------------------------
|
|
32
72
|
app.post("/v1/sessions", async (c) => {
|
|
33
73
|
const body = (await c.req.json().catch(() => null));
|
|
34
74
|
if (body === null)
|
|
@@ -61,13 +101,15 @@ export function createServer(opts) {
|
|
|
61
101
|
const s = await c.var.ctx.storage.getSession(id);
|
|
62
102
|
if (!s)
|
|
63
103
|
return c.json({ error: "session_not_found" }, 404);
|
|
64
|
-
|
|
104
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, [s]);
|
|
105
|
+
return c.json({ session: s, persons });
|
|
65
106
|
});
|
|
66
107
|
app.get("/v1/sessions", async (c) => {
|
|
67
108
|
const limit = clampLimit(c, defaultLimit, maxLimit);
|
|
68
109
|
const channel = c.req.query("channel") || undefined;
|
|
69
110
|
const sessions = await c.var.ctx.storage.listSessions({ limit, channel });
|
|
70
|
-
|
|
111
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, sessions);
|
|
112
|
+
return c.json({ sessions, persons });
|
|
71
113
|
});
|
|
72
114
|
app.post("/v1/search", async (c) => {
|
|
73
115
|
const body = (await c.req.json().catch(() => null));
|
|
@@ -91,7 +133,61 @@ export function createServer(opts) {
|
|
|
91
133
|
...opts,
|
|
92
134
|
queryParticipants,
|
|
93
135
|
});
|
|
94
|
-
|
|
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 });
|
|
95
191
|
});
|
|
96
192
|
return app;
|
|
97
193
|
}
|
|
@@ -104,3 +200,25 @@ function clampLimit(c, def, max) {
|
|
|
104
200
|
return def;
|
|
105
201
|
return Math.min(n, max);
|
|
106
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexis-ai/engram-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Engram server: ingest agent session events, persist via a pluggable adapter, expose search.",
|
|
5
5
|
"keywords": ["engram", "agents", "search", "hono", "postgres", "server"],
|
|
6
6
|
"homepage": "https://github.com/hexis-ltd/engram#readme",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"dev": "bun --hot src/dev.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@hexis-ai/engram-core": "^0.1.
|
|
45
|
-
"@hexis-ai/engram-sdk": "^0.1.
|
|
44
|
+
"@hexis-ai/engram-core": "^0.1.5",
|
|
45
|
+
"@hexis-ai/engram-sdk": "^0.1.5",
|
|
46
46
|
"hono": "^4.6.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|