@hexis-ai/engram-server 0.4.0 → 0.5.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.
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } 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. */
@@ -12,6 +12,8 @@ export interface InMemoryAdapterOptions {
12
12
  export declare class InMemoryAdapter implements StorageAdapter {
13
13
  private readonly sessions;
14
14
  private readonly persons;
15
+ /** Keyed by `${personId} ${name.toLowerCase()}` — see `aliasKey` below. */
16
+ private readonly aliases;
15
17
  private readonly newPersonId;
16
18
  constructor(opts?: InMemoryAdapterOptions);
17
19
  createSession(init: SessionInit & {
@@ -39,4 +41,8 @@ export declare class InMemoryAdapter implements StorageAdapter {
39
41
  limit: number;
40
42
  q?: string;
41
43
  }): Promise<PersonInfo[]>;
44
+ upsertAlias(personId: string, input: {
45
+ name: string;
46
+ } & AliasUpsert): Promise<AliasInfo | null>;
47
+ listAliases(personId: string): Promise<AliasInfo[]>;
42
48
  }
Binary file
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } 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
@@ -56,4 +56,8 @@ export declare class PostgresAdapter implements StorageAdapter {
56
56
  limit: number;
57
57
  q?: string;
58
58
  }): Promise<PersonInfo[]>;
59
+ upsertAlias(personId: string, input: {
60
+ name: string;
61
+ } & AliasUpsert): Promise<AliasInfo | null>;
62
+ listAliases(personId: string): Promise<AliasInfo[]>;
59
63
  }
@@ -237,6 +237,57 @@ export class PostgresAdapter {
237
237
  `;
238
238
  return rows.map(toPersonInfo);
239
239
  }
240
+ // --- Aliases ------------------------------------------------------
241
+ async upsertAlias(personId, input) {
242
+ // Pre-check rather than rely on the FK so unknown persons return
243
+ // `null` instead of throwing a constraint violation.
244
+ const personExists = await this.sql `
245
+ SELECT id FROM engram_persons
246
+ WHERE workspace_id = ${this.workspaceId} AND id = ${personId}
247
+ LIMIT 1
248
+ `;
249
+ if (personExists.length === 0)
250
+ return null;
251
+ const increment = input.increment ?? true;
252
+ // Branch on the upsert behaviour. `increment=true` bumps usage_count
253
+ // and replaces caller/last_used; `increment=false` is idempotent —
254
+ // a no-op when the row already exists.
255
+ const rows = increment
256
+ ? await this.sql `
257
+ INSERT INTO engram_aliases (workspace_id, person_id, name, caller, usage_count, last_used)
258
+ VALUES (
259
+ ${this.workspaceId}, ${personId}, ${input.name},
260
+ ${input.caller}, 1, ${input.last_used}
261
+ )
262
+ ON CONFLICT (workspace_id, person_id, name_lower) DO UPDATE SET
263
+ usage_count = engram_aliases.usage_count + 1,
264
+ caller = EXCLUDED.caller,
265
+ last_used = EXCLUDED.last_used,
266
+ updated_at = now()
267
+ RETURNING person_id, name, caller, usage_count, last_used, created_at, updated_at
268
+ `
269
+ : await this.sql `
270
+ INSERT INTO engram_aliases (workspace_id, person_id, name, caller, usage_count, last_used)
271
+ VALUES (
272
+ ${this.workspaceId}, ${personId}, ${input.name},
273
+ ${input.caller}, 1, ${input.last_used}
274
+ )
275
+ ON CONFLICT (workspace_id, person_id, name_lower) DO UPDATE SET
276
+ -- No-op update so RETURNING still produces a row.
277
+ updated_at = engram_aliases.updated_at
278
+ RETURNING person_id, name, caller, usage_count, last_used, created_at, updated_at
279
+ `;
280
+ return toAliasInfo(rows[0]);
281
+ }
282
+ async listAliases(personId) {
283
+ const rows = await this.sql `
284
+ SELECT person_id, name, caller, usage_count, last_used, created_at, updated_at
285
+ FROM engram_aliases
286
+ WHERE workspace_id = ${this.workspaceId} AND person_id = ${personId}
287
+ ORDER BY last_used DESC
288
+ `;
289
+ return rows.map(toAliasInfo);
290
+ }
240
291
  }
241
292
  function toPersonInfo(r) {
242
293
  const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
@@ -247,3 +298,20 @@ function toPersonInfo(r) {
247
298
  updated_at: toIso(r.updated_at),
248
299
  };
249
300
  }
301
+ function toAliasInfo(r) {
302
+ const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
303
+ // last_used is a DATE column; postgres-js returns it as a Date at UTC
304
+ // midnight. Render as plain YYYY-MM-DD to match the wire type.
305
+ const lastUsed = typeof r.last_used === "string"
306
+ ? r.last_used.slice(0, 10)
307
+ : r.last_used.toISOString().slice(0, 10);
308
+ return {
309
+ person_id: r.person_id,
310
+ name: r.name,
311
+ caller: r.caller,
312
+ usage_count: r.usage_count,
313
+ last_used: lastUsed,
314
+ created_at: toIso(r.created_at),
315
+ updated_at: toIso(r.updated_at),
316
+ };
317
+ }
@@ -0,0 +1,2 @@
1
+ export declare const name = "0002-aliases";
2
+ export declare const sql = "\n-- Per-person alias history. Mirrors monet's `aliases` table at the\n-- canonical-engram level so identity resolution can move out of monet\n-- once consumers catch up. Keyed by name_lower (case-insensitive,\n-- DB-generated) so case variants collapse.\nCREATE TABLE IF NOT EXISTS engram_aliases (\n workspace_id TEXT NOT NULL,\n person_id TEXT NOT NULL,\n name TEXT NOT NULL,\n name_lower TEXT GENERATED ALWAYS AS (lower(name)) STORED,\n caller TEXT NOT NULL,\n usage_count INTEGER NOT NULL DEFAULT 1,\n last_used DATE NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n PRIMARY KEY (workspace_id, person_id, name_lower),\n FOREIGN KEY (workspace_id, person_id)\n REFERENCES engram_persons(workspace_id, id) ON DELETE CASCADE\n);\n\n-- Reverse lookup: \"who answers to this name?\" is the common identity-\n-- resolution query and benefits from an index on the lowercased form.\nCREATE INDEX IF NOT EXISTS idx_engram_aliases_name_lower\n ON engram_aliases (workspace_id, name_lower);\n\n-- Forward lookup: list all of a person's aliases by recency.\nCREATE INDEX IF NOT EXISTS idx_engram_aliases_person_last_used\n ON engram_aliases (workspace_id, person_id, last_used DESC);\n";
@@ -0,0 +1,30 @@
1
+ export const name = "0002-aliases";
2
+ export const sql = `
3
+ -- Per-person alias history. Mirrors monet's \`aliases\` table at the
4
+ -- canonical-engram level so identity resolution can move out of monet
5
+ -- once consumers catch up. Keyed by name_lower (case-insensitive,
6
+ -- DB-generated) so case variants collapse.
7
+ CREATE TABLE IF NOT EXISTS engram_aliases (
8
+ workspace_id TEXT NOT NULL,
9
+ person_id TEXT NOT NULL,
10
+ name TEXT NOT NULL,
11
+ name_lower TEXT GENERATED ALWAYS AS (lower(name)) STORED,
12
+ caller TEXT NOT NULL,
13
+ usage_count INTEGER NOT NULL DEFAULT 1,
14
+ last_used DATE NOT NULL,
15
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
16
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
17
+ PRIMARY KEY (workspace_id, person_id, name_lower),
18
+ FOREIGN KEY (workspace_id, person_id)
19
+ REFERENCES engram_persons(workspace_id, id) ON DELETE CASCADE
20
+ );
21
+
22
+ -- Reverse lookup: \"who answers to this name?\" is the common identity-
23
+ -- resolution query and benefits from an index on the lowercased form.
24
+ CREATE INDEX IF NOT EXISTS idx_engram_aliases_name_lower
25
+ ON engram_aliases (workspace_id, name_lower);
26
+
27
+ -- Forward lookup: list all of a person's aliases by recency.
28
+ CREATE INDEX IF NOT EXISTS idx_engram_aliases_person_last_used
29
+ ON engram_aliases (workspace_id, person_id, last_used DESC);
30
+ `;
@@ -1,4 +1,5 @@
1
1
  import * as m0001 from "./0001-baseline";
2
+ import * as m0002 from "./0002-aliases";
2
3
  /**
3
4
  * Schema migrations, applied in array order. Add a new file under
4
5
  * `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
@@ -6,4 +7,7 @@ import * as m0001 from "./0001-baseline";
6
7
  * EXISTS, ADD COLUMN IF NOT EXISTS, etc.) so a first apply on a DB that
7
8
  * predates the migrator is a no-op.
8
9
  */
9
- export const MIGRATIONS = [{ name: m0001.name, sql: m0001.sql }];
10
+ export const MIGRATIONS = [
11
+ { name: m0001.name, sql: m0001.sql },
12
+ { name: m0002.name, sql: m0002.sql },
13
+ ];
package/dist/openapi.js CHANGED
@@ -231,6 +231,32 @@ function buildPaths() {
231
231
  },
232
232
  },
233
233
  }),
234
+ "/v1/persons/{id}/aliases": tagged("Persons", {
235
+ get: {
236
+ summary: "この person の alias 一覧を取得する(直近使用が先頭)。",
237
+ parameters: [pathParam("id", "person id。")],
238
+ responses: {
239
+ "200": res("alias 一覧"),
240
+ "401": res("認証エラー"),
241
+ },
242
+ },
243
+ }),
244
+ "/v1/persons/{id}/aliases/{name}": tagged("Persons", {
245
+ put: {
246
+ summary: "person に alias を upsert する。name は case-insensitive で比較される。",
247
+ parameters: [
248
+ pathParam("id", "person id。"),
249
+ pathParam("name", "alias の名前(URL-encoded)。"),
250
+ ],
251
+ requestBody: jsonBody("AliasUpsert"),
252
+ responses: {
253
+ "200": res("upsert された alias"),
254
+ "400": res("リクエストボディが不正"),
255
+ "404": res("person が見つからない"),
256
+ "401": res("認証エラー"),
257
+ },
258
+ },
259
+ }),
234
260
  "/admin/v1/workspaces": tagged("Workspaces (admin)", {
235
261
  post: {
236
262
  summary: "ワークスペースを作成する(デフォルトで初期キーも発行する)。",
@@ -3,11 +3,13 @@ import type { Env } from "../context";
3
3
  import { type RouteConfig } from "./helpers";
4
4
  /**
5
5
  * Person routes. Mount under `/v1`:
6
- * POST /v1/persons create a person (server allocates the id)
7
- * PUT /v1/persons/:id upsert at a host-supplied id
8
- * PATCH /v1/persons/:id patch profile fields
9
- * GET /v1/persons/:id fetch one person
10
- * GET /v1/persons list / free-text search persons
11
- * GET /v1/persons/:id/sessions sessions this person participates in / can view
6
+ * POST /v1/persons create a person (server allocates the id)
7
+ * PUT /v1/persons/:id upsert at a host-supplied id
8
+ * PATCH /v1/persons/:id patch profile fields
9
+ * GET /v1/persons/:id fetch one person
10
+ * GET /v1/persons list / free-text search persons
11
+ * GET /v1/persons/:id/sessions sessions this person participates in / can view
12
+ * PUT /v1/persons/:id/aliases/:name upsert an alias for this person
13
+ * GET /v1/persons/:id/aliases list aliases for this person (newest-used-first)
12
14
  */
13
15
  export declare function personsRoutes(cfg: RouteConfig): Hono<Env>;
@@ -1,14 +1,16 @@
1
1
  import { Hono } from "hono";
2
- import { parseJsonBody, personCreateSchema, personUpdateSchema } from "../schemas";
2
+ import { aliasUpsertSchema, parseJsonBody, personCreateSchema, personUpdateSchema, } from "../schemas";
3
3
  import { clampLimit, resolvePersonMap } from "./helpers";
4
4
  /**
5
5
  * Person routes. Mount under `/v1`:
6
- * POST /v1/persons create a person (server allocates the id)
7
- * PUT /v1/persons/:id upsert at a host-supplied id
8
- * PATCH /v1/persons/:id patch profile fields
9
- * GET /v1/persons/:id fetch one person
10
- * GET /v1/persons list / free-text search persons
11
- * GET /v1/persons/:id/sessions sessions this person participates in / can view
6
+ * POST /v1/persons create a person (server allocates the id)
7
+ * PUT /v1/persons/:id upsert at a host-supplied id
8
+ * PATCH /v1/persons/:id patch profile fields
9
+ * GET /v1/persons/:id fetch one person
10
+ * GET /v1/persons list / free-text search persons
11
+ * GET /v1/persons/:id/sessions sessions this person participates in / can view
12
+ * PUT /v1/persons/:id/aliases/:name upsert an alias for this person
13
+ * GET /v1/persons/:id/aliases list aliases for this person (newest-used-first)
12
14
  */
13
15
  export function personsRoutes(cfg) {
14
16
  const app = new Hono();
@@ -50,6 +52,22 @@ export function personsRoutes(cfg) {
50
52
  const persons = await c.var.ctx.storage.listPersons({ limit, q });
51
53
  return c.json({ persons });
52
54
  });
55
+ app.put("/persons/:id/aliases/:name", async (c) => {
56
+ const id = c.req.param("id");
57
+ const name = c.req.param("name");
58
+ const body = await parseJsonBody(c, aliasUpsertSchema);
59
+ if (body instanceof Response)
60
+ return body;
61
+ const alias = await c.var.ctx.storage.upsertAlias(id, { name, ...body });
62
+ if (!alias)
63
+ return c.json({ error: "person_not_found" }, 404);
64
+ return c.json(alias);
65
+ });
66
+ app.get("/persons/:id/aliases", async (c) => {
67
+ const id = c.req.param("id");
68
+ const aliases = await c.var.ctx.storage.listAliases(id);
69
+ return c.json({ aliases });
70
+ });
53
71
  app.get("/persons/:id/sessions", async (c) => {
54
72
  const id = c.req.param("id");
55
73
  const limit = clampLimit(c, cfg.defaultListLimit, cfg.maxListLimit);
package/dist/schemas.d.ts CHANGED
@@ -66,6 +66,11 @@ export declare const personCreateSchema: z.ZodObject<{
66
66
  export declare const personUpdateSchema: z.ZodObject<{
67
67
  display_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
68
68
  }, z.core.$strip>;
69
+ export declare const aliasUpsertSchema: z.ZodObject<{
70
+ caller: z.ZodString;
71
+ last_used: z.ZodString;
72
+ increment: z.ZodOptional<z.ZodBoolean>;
73
+ }, z.core.$strip>;
69
74
  export declare const searchRequestSchema: z.ZodObject<{
70
75
  query: z.ZodUnion<readonly [z.ZodObject<{
71
76
  sessionId: z.ZodString;
package/dist/schemas.js CHANGED
@@ -56,6 +56,16 @@ export const personCreateSchema = z.object({
56
56
  export const personUpdateSchema = z.object({
57
57
  display_name: z.string().nullable().optional(),
58
58
  });
59
+ // --- Aliases ----------------------------------------------------------
60
+ export const aliasUpsertSchema = z.object({
61
+ caller: z.string().min(1),
62
+ // YYYY-MM-DD; loose-validate so callers can also pass an ISO timestamp
63
+ // and the server will accept the date prefix.
64
+ last_used: z
65
+ .string()
66
+ .regex(/^\d{4}-\d{2}-\d{2}/, "expected YYYY-MM-DD"),
67
+ increment: z.boolean().optional(),
68
+ });
59
69
  // --- Search ----------------------------------------------------------
60
70
  const searchQuerySchema = z.union([
61
71
  z.object({ sessionId: z.string().min(1) }),
package/dist/server.js CHANGED
@@ -58,6 +58,8 @@ export function createServer(opts) {
58
58
  persons: "POST/GET /v1/persons",
59
59
  personById: "GET/PUT/PATCH /v1/persons/:id",
60
60
  personSessions: "GET /v1/persons/:id/sessions",
61
+ personAliases: "GET /v1/persons/:id/aliases",
62
+ upsertAlias: "PUT /v1/persons/:id/aliases/:name",
61
63
  },
62
64
  }));
63
65
  app.get("/healthz", (c) => c.json({ ok: true }));
@@ -77,7 +79,8 @@ export function createServer(opts) {
77
79
  await next();
78
80
  });
79
81
  // Identity probe — echoes the workspace the caller's key resolves to.
80
- // Used by clients (e.g. engram-web) to label which tenant they're viewing.
82
+ // Used by host clients (e.g. monet's `/v1/engram/*` proxy) to label
83
+ // which tenant they're viewing.
81
84
  app.get("/v1/me", (c) => c.json({ workspaceId: c.var.ctx.workspaceId }));
82
85
  app.route("/v1", sessionsRoutes(cfg));
83
86
  app.route("/v1", personsRoutes(cfg));
package/dist/storage.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } 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
@@ -60,6 +60,23 @@ export interface StorageAdapter {
60
60
  channel?: string;
61
61
  scope?: "participant" | "viewable";
62
62
  }): Promise<Session[]>;
63
+ /**
64
+ * Upsert a name → person mapping. Names collapse case-insensitively
65
+ * (the row is keyed by `(person_id, lower(name))`).
66
+ *
67
+ * - `increment: true` (default): if the alias already exists, bump
68
+ * `usage_count` by 1 and replace `caller` / `last_used` with the
69
+ * input values.
70
+ * - `increment: false`: if the row already exists, do not touch it.
71
+ *
72
+ * Returns `null` if `personId` does not exist (engram_aliases FKs into
73
+ * engram_persons).
74
+ */
75
+ upsertAlias(personId: string, input: {
76
+ name: string;
77
+ } & AliasUpsert): Promise<AliasInfo | null>;
78
+ /** A person's aliases, ordered newest-used-first. */
79
+ listAliases(personId: string): Promise<AliasInfo[]>;
63
80
  }
64
81
  /**
65
82
  * Pure fold of an event log into the parts a Session needs. Used by adapters
package/openapi.json CHANGED
@@ -798,6 +798,85 @@
798
798
  ]
799
799
  }
800
800
  },
801
+ "/v1/persons/{id}/aliases": {
802
+ "get": {
803
+ "summary": "この person の alias 一覧を取得する(直近使用が先頭)。",
804
+ "parameters": [
805
+ {
806
+ "name": "id",
807
+ "in": "path",
808
+ "required": true,
809
+ "schema": {
810
+ "type": "string"
811
+ },
812
+ "description": "person id。"
813
+ }
814
+ ],
815
+ "responses": {
816
+ "200": {
817
+ "description": "alias 一覧"
818
+ },
819
+ "401": {
820
+ "description": "認証エラー"
821
+ }
822
+ },
823
+ "tags": [
824
+ "Persons"
825
+ ]
826
+ }
827
+ },
828
+ "/v1/persons/{id}/aliases/{name}": {
829
+ "put": {
830
+ "summary": "person に alias を upsert する。name は case-insensitive で比較される。",
831
+ "parameters": [
832
+ {
833
+ "name": "id",
834
+ "in": "path",
835
+ "required": true,
836
+ "schema": {
837
+ "type": "string"
838
+ },
839
+ "description": "person id。"
840
+ },
841
+ {
842
+ "name": "name",
843
+ "in": "path",
844
+ "required": true,
845
+ "schema": {
846
+ "type": "string"
847
+ },
848
+ "description": "alias の名前(URL-encoded)。"
849
+ }
850
+ ],
851
+ "requestBody": {
852
+ "required": true,
853
+ "content": {
854
+ "application/json": {
855
+ "schema": {
856
+ "$ref": "#/components/schemas/AliasUpsert"
857
+ }
858
+ }
859
+ }
860
+ },
861
+ "responses": {
862
+ "200": {
863
+ "description": "upsert された alias"
864
+ },
865
+ "400": {
866
+ "description": "リクエストボディが不正"
867
+ },
868
+ "401": {
869
+ "description": "認証エラー"
870
+ },
871
+ "404": {
872
+ "description": "person が見つからない"
873
+ }
874
+ },
875
+ "tags": [
876
+ "Persons"
877
+ ]
878
+ }
879
+ },
801
880
  "/admin/v1/workspaces": {
802
881
  "post": {
803
882
  "summary": "ワークスペースを作成する(デフォルトで初期キーも発行する)。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexis-ai/engram-server",
3
- "version": "0.4.0",
3
+ "version": "0.5.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.3.0",
53
+ "@hexis-ai/engram-sdk": "^0.4.0",
54
54
  "hono": "^4.6.0",
55
55
  "zod": "^4.0.0"
56
56
  },