@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/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
- // Unauthenticated service info. Useful for browser sanity checks and
9
- // health probes (Cloud Run, k8s, uptime monitors).
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 auth = c.req.header("authorization");
23
- const m = auth?.match(/^Bearer\s+(.+)$/i);
24
- if (!m)
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(m[1]);
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
- return c.json(s);
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
- return c.json(sessions);
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
- return c.json({ results });
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 concern: the
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
- /** Create a session row. Returns the assigned id. */
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.identityRef);
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.2",
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.2",
45
- "@hexis-ai/engram-sdk": "^0.1.2",
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": {