@hexis-ai/engram-server 0.16.0 → 0.17.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,9 +1,11 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit, SessionUpdate } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonMemory, PersonMemoryCreate, 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. */
6
6
  newPersonId?: () => string;
7
+ /** Override for tests. Default: `mem_${random}` with 12 chars. */
8
+ newMemoryId?: () => string;
7
9
  }
8
10
  /**
9
11
  * In-process storage adapter for tests, dev, and small single-node deploys.
@@ -16,7 +18,10 @@ export declare class InMemoryAdapter implements StorageAdapter {
16
18
  private readonly aliases;
17
19
  /** Keyed by ref (e.g. `slack:U12345`). */
18
20
  private readonly identities;
21
+ /** Keyed by memory id. */
22
+ private readonly memories;
19
23
  private readonly newPersonId;
24
+ private readonly newMemoryId;
20
25
  constructor(opts?: InMemoryAdapterOptions);
21
26
  createSession(init: SessionInit & {
22
27
  id: string;
@@ -43,6 +48,7 @@ export declare class InMemoryAdapter implements StorageAdapter {
43
48
  createPerson(input: PersonCreate): Promise<PersonInfo>;
44
49
  upsertPerson(id: string, input: PersonCreate): Promise<PersonInfo>;
45
50
  updatePerson(id: string, patch: PersonUpdate): Promise<PersonInfo | null>;
51
+ deletePerson(id: string): Promise<boolean>;
46
52
  getPerson(id: string): Promise<PersonInfo | null>;
47
53
  getPersons(ids: string[]): Promise<PersonInfo[]>;
48
54
  listPersons(opts: {
@@ -53,8 +59,14 @@ export declare class InMemoryAdapter implements StorageAdapter {
53
59
  name: string;
54
60
  } & AliasUpsert): Promise<AliasInfo | null>;
55
61
  listAliases(personId: string): Promise<AliasInfo[]>;
62
+ deleteAlias(personId: string, name: string): Promise<boolean>;
56
63
  findAliasesByName(name: string): Promise<AliasInfo[]>;
57
64
  upsertIdentity(ref: string, input: IdentityUpsert): Promise<IdentityInfo | null>;
58
65
  getIdentityByRef(ref: string): Promise<IdentityInfo | null>;
59
66
  listIdentitiesByPerson(personId: string): Promise<IdentityInfo[]>;
67
+ addPersonMemory(personId: string, input: PersonMemoryCreate): Promise<PersonMemory | null>;
68
+ listPersonMemories(personId: string, opts: {
69
+ limit: number;
70
+ }): Promise<PersonMemory[]>;
71
+ deletePersonMemory(memoryId: string): Promise<boolean>;
60
72
  }
@@ -1,4 +1,4 @@
1
- import { foldEvents, newPersonId } from "../storage";
1
+ import { foldEvents, newPersonId, newPersonMemoryId, } from "../storage";
2
2
  import { applyPartial } from "./util";
3
3
  /**
4
4
  * In-process storage adapter for tests, dev, and small single-node deploys.
@@ -11,9 +11,13 @@ export class InMemoryAdapter {
11
11
  aliases = new Map();
12
12
  /** Keyed by ref (e.g. `slack:U12345`). */
13
13
  identities = new Map();
14
+ /** Keyed by memory id. */
15
+ memories = new Map();
14
16
  newPersonId;
17
+ newMemoryId;
15
18
  constructor(opts = {}) {
16
19
  this.newPersonId = opts.newPersonId ?? newPersonId;
20
+ this.newMemoryId = opts.newMemoryId ?? newPersonMemoryId;
17
21
  }
18
22
  // --- Sessions -----------------------------------------------------
19
23
  async createSession(init) {
@@ -188,6 +192,27 @@ export class InMemoryAdapter {
188
192
  this.persons.set(id, next);
189
193
  return next;
190
194
  }
195
+ async deletePerson(id) {
196
+ if (!this.persons.has(id))
197
+ return false;
198
+ this.persons.delete(id);
199
+ // Mirror the postgres FK cascade so behaviour is symmetric:
200
+ // dependent aliases / identities / memories vanish with the
201
+ // person.
202
+ for (const [key, alias] of this.aliases) {
203
+ if (alias.person_id === id)
204
+ this.aliases.delete(key);
205
+ }
206
+ for (const [ref, identity] of this.identities) {
207
+ if (identity.person_id === id)
208
+ this.identities.delete(ref);
209
+ }
210
+ for (const [memId, memory] of this.memories) {
211
+ if (memory.person_id === id)
212
+ this.memories.delete(memId);
213
+ }
214
+ return true;
215
+ }
191
216
  async getPerson(id) {
192
217
  return this.persons.get(id) ?? null;
193
218
  }
@@ -268,6 +293,9 @@ export class InMemoryAdapter {
268
293
  matches.sort((a, b) => b.last_used.localeCompare(a.last_used));
269
294
  return matches;
270
295
  }
296
+ async deleteAlias(personId, name) {
297
+ return this.aliases.delete(aliasKey(personId, name));
298
+ }
271
299
  async findAliasesByName(name) {
272
300
  const lower = name.toLowerCase();
273
301
  const matches = [...this.aliases.values()].filter((a) => a.name.toLowerCase() === lower);
@@ -328,6 +356,31 @@ export class InMemoryAdapter {
328
356
  matches.sort((a, b) => b.linked_at.localeCompare(a.linked_at));
329
357
  return matches;
330
358
  }
359
+ // --- Person memories ---------------------------------------------
360
+ async addPersonMemory(personId, input) {
361
+ if (!this.persons.has(personId))
362
+ return null;
363
+ const now = new Date().toISOString();
364
+ const memory = {
365
+ id: this.newMemoryId(),
366
+ person_id: personId,
367
+ content: input.content,
368
+ source: input.source ?? "agent",
369
+ source_session_id: input.source_session_id ?? null,
370
+ created_at: now,
371
+ updated_at: now,
372
+ };
373
+ this.memories.set(memory.id, memory);
374
+ return memory;
375
+ }
376
+ async listPersonMemories(personId, opts) {
377
+ const matches = [...this.memories.values()].filter((m) => m.person_id === personId);
378
+ matches.sort((a, b) => b.created_at.localeCompare(a.created_at));
379
+ return matches.slice(0, opts.limit);
380
+ }
381
+ async deletePersonMemory(memoryId) {
382
+ return this.memories.delete(memoryId);
383
+ }
331
384
  }
332
385
  function aliasKey(personId, name) {
333
386
  return `${personId}${name.toLowerCase()}`;
@@ -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, SessionUpdate } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonMemory, PersonMemoryCreate, 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
@@ -26,11 +26,17 @@ export interface PostgresAdapterOptions {
26
26
  * Override in tests for determinism.
27
27
  */
28
28
  newPersonId?: () => string;
29
+ /**
30
+ * Generates ids for newly created person-memories. Default:
31
+ * `mem_${randomShort()}`. Override in tests for determinism.
32
+ */
33
+ newMemoryId?: () => string;
29
34
  }
30
35
  export declare class PostgresAdapter implements StorageAdapter {
31
36
  private readonly workspaceId;
32
37
  private readonly sql;
33
38
  private readonly newPersonId;
39
+ private readonly newMemoryId;
34
40
  constructor(opts: PostgresAdapterOptions);
35
41
  /**
36
42
  * Apply all pending schema migrations. Safe to call repeatedly and
@@ -62,6 +68,7 @@ export declare class PostgresAdapter implements StorageAdapter {
62
68
  createPerson(input: PersonCreate): Promise<PersonInfo>;
63
69
  upsertPerson(id: string, input: PersonCreate): Promise<PersonInfo>;
64
70
  updatePerson(id: string, patch: PersonUpdate): Promise<PersonInfo | null>;
71
+ deletePerson(id: string): Promise<boolean>;
65
72
  getPerson(id: string): Promise<PersonInfo | null>;
66
73
  getPersons(ids: string[]): Promise<PersonInfo[]>;
67
74
  listPersons(opts: {
@@ -72,8 +79,14 @@ export declare class PostgresAdapter implements StorageAdapter {
72
79
  name: string;
73
80
  } & AliasUpsert): Promise<AliasInfo | null>;
74
81
  listAliases(personId: string): Promise<AliasInfo[]>;
82
+ deleteAlias(personId: string, name: string): Promise<boolean>;
75
83
  findAliasesByName(name: string): Promise<AliasInfo[]>;
76
84
  upsertIdentity(ref: string, input: IdentityUpsert): Promise<IdentityInfo | null>;
77
85
  getIdentityByRef(ref: string): Promise<IdentityInfo | null>;
78
86
  listIdentitiesByPerson(personId: string): Promise<IdentityInfo[]>;
87
+ addPersonMemory(personId: string, input: PersonMemoryCreate): Promise<PersonMemory | null>;
88
+ listPersonMemories(personId: string, opts: {
89
+ limit: number;
90
+ }): Promise<PersonMemory[]>;
91
+ deletePersonMemory(memoryId: string): Promise<boolean>;
79
92
  }
@@ -1,14 +1,16 @@
1
1
  import { runMigrations } from "../migrator";
2
- import { foldEvents, newPersonId } from "../storage";
2
+ import { foldEvents, newPersonId, newPersonMemoryId, } from "../storage";
3
3
  import { isoString, pickPatch } from "./util";
4
4
  export class PostgresAdapter {
5
5
  workspaceId;
6
6
  sql;
7
7
  newPersonId;
8
+ newMemoryId;
8
9
  constructor(opts) {
9
10
  this.workspaceId = opts.workspaceId;
10
11
  this.sql = opts.sql;
11
12
  this.newPersonId = opts.newPersonId ?? newPersonId;
13
+ this.newMemoryId = opts.newMemoryId ?? newPersonMemoryId;
12
14
  }
13
15
  /**
14
16
  * Apply all pending schema migrations. Safe to call repeatedly and
@@ -304,6 +306,17 @@ export class PostgresAdapter {
304
306
  return null;
305
307
  return toPersonInfo(rows[0]);
306
308
  }
309
+ async deletePerson(id) {
310
+ // Aliases / identities / memories vanish via FK ON DELETE CASCADE.
311
+ // Sessions are not FK'd to engram_persons (participant ids are
312
+ // opaque text) and are intentionally left alone.
313
+ const rows = await this.sql `
314
+ DELETE FROM engram_persons
315
+ WHERE workspace_id = ${this.workspaceId} AND id = ${id}
316
+ RETURNING id
317
+ `;
318
+ return rows.length > 0;
319
+ }
307
320
  async getPerson(id) {
308
321
  const rows = await this.sql `
309
322
  SELECT id, display_name, role, team, source, created_at, updated_at
@@ -412,6 +425,18 @@ export class PostgresAdapter {
412
425
  `;
413
426
  return rows.map(toAliasInfo);
414
427
  }
428
+ async deleteAlias(personId, name) {
429
+ // name_lower (generated stored) keeps this index-only regardless
430
+ // of the input casing.
431
+ const rows = await this.sql `
432
+ DELETE FROM engram_aliases
433
+ WHERE workspace_id = ${this.workspaceId}
434
+ AND person_id = ${personId}
435
+ AND name_lower = ${name.toLowerCase()}
436
+ RETURNING name
437
+ `;
438
+ return rows.length > 0;
439
+ }
415
440
  async findAliasesByName(name) {
416
441
  // The `name_lower` column is a STORED generated lower(name) backed
417
442
  // by `idx_engram_aliases_name_lower (workspace_id, name_lower)`, so
@@ -495,6 +520,51 @@ export class PostgresAdapter {
495
520
  `;
496
521
  return rows.map(toIdentityInfo);
497
522
  }
523
+ // --- Person memories ----------------------------------------------
524
+ async addPersonMemory(personId, input) {
525
+ // Existence-check + insert in one round-trip via INSERT…SELECT.
526
+ // Unlike aliases/identities the adapter does NOT auto-create a
527
+ // stub person — memories are first-class facts about a known
528
+ // person; writing one for an unknown id is almost always a bug.
529
+ const id = this.newMemoryId();
530
+ const rows = await this.sql `
531
+ INSERT INTO engram_person_memories (
532
+ workspace_id, id, person_id, content, source, source_session_id
533
+ )
534
+ SELECT
535
+ ${this.workspaceId}, ${id}, ${personId},
536
+ ${input.content}, ${input.source ?? "agent"},
537
+ ${input.source_session_id ?? null}
538
+ WHERE EXISTS (
539
+ SELECT 1 FROM engram_persons
540
+ WHERE workspace_id = ${this.workspaceId} AND id = ${personId}
541
+ )
542
+ RETURNING id, person_id, content, source, source_session_id,
543
+ created_at, updated_at
544
+ `;
545
+ if (rows.length === 0)
546
+ return null;
547
+ return toPersonMemory(rows[0]);
548
+ }
549
+ async listPersonMemories(personId, opts) {
550
+ const rows = await this.sql `
551
+ SELECT id, person_id, content, source, source_session_id,
552
+ created_at, updated_at
553
+ FROM engram_person_memories
554
+ WHERE workspace_id = ${this.workspaceId} AND person_id = ${personId}
555
+ ORDER BY created_at DESC
556
+ LIMIT ${opts.limit}
557
+ `;
558
+ return rows.map(toPersonMemory);
559
+ }
560
+ async deletePersonMemory(memoryId) {
561
+ const rows = await this.sql `
562
+ DELETE FROM engram_person_memories
563
+ WHERE workspace_id = ${this.workspaceId} AND id = ${memoryId}
564
+ RETURNING id
565
+ `;
566
+ return rows.length > 0;
567
+ }
498
568
  }
499
569
  function toPersonInfo(r) {
500
570
  return {
@@ -570,3 +640,14 @@ function toIdentityInfo(r) {
570
640
  updated_at: isoString(r.updated_at),
571
641
  };
572
642
  }
643
+ function toPersonMemory(r) {
644
+ return {
645
+ id: r.id,
646
+ person_id: r.person_id,
647
+ content: r.content,
648
+ source: r.source,
649
+ source_session_id: r.source_session_id,
650
+ created_at: isoString(r.created_at),
651
+ updated_at: isoString(r.updated_at),
652
+ };
653
+ }
@@ -0,0 +1,2 @@
1
+ export declare const name = "0010-person-memories";
2
+ export declare const sql = "\n-- Per-person free-form memory store. ChatGPT/Claude-style \"facts about\n-- this person\" that the host agent reads on each turn and writes via a\n-- save_person_memory tool. Workspace-scoped, soft-FK to engram_persons\n-- so a memory row cannot outlive its person.\nCREATE TABLE IF NOT EXISTS engram_person_memories (\n workspace_id TEXT NOT NULL,\n id TEXT NOT NULL,\n person_id TEXT NOT NULL,\n content TEXT NOT NULL,\n -- Where the memory came from. 'agent' = saved by the LLM via tool,\n -- 'manual' = entered by a human (engram-web UI), 'auto' = derived by\n -- a pipeline (future). Mirrors engram_persons.source so callers can\n -- filter/render provenance consistently.\n source TEXT NOT NULL DEFAULT 'agent',\n -- Session that produced this memory. Nullable because UI-entered\n -- memories have no originating session. No FK \u2014 sessions can be\n -- purged independently and we don't want a CASCADE to vaporise the\n -- memory.\n source_session_id TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n PRIMARY KEY (workspace_id, id),\n FOREIGN KEY (workspace_id, person_id)\n REFERENCES engram_persons (workspace_id, id) ON DELETE CASCADE\n);\n\n-- Hot path: \"give me this person's N most recent memories\" \u2014 fired\n-- on every agent turn for every participant. Index covers the\n-- (workspace, person) filter and the created_at desc sort.\nCREATE INDEX IF NOT EXISTS idx_engram_person_memories_person_created\n ON engram_person_memories (workspace_id, person_id, created_at DESC);\n";
@@ -0,0 +1,34 @@
1
+ export const name = "0010-person-memories";
2
+ export const sql = `
3
+ -- Per-person free-form memory store. ChatGPT/Claude-style "facts about
4
+ -- this person" that the host agent reads on each turn and writes via a
5
+ -- save_person_memory tool. Workspace-scoped, soft-FK to engram_persons
6
+ -- so a memory row cannot outlive its person.
7
+ CREATE TABLE IF NOT EXISTS engram_person_memories (
8
+ workspace_id TEXT NOT NULL,
9
+ id TEXT NOT NULL,
10
+ person_id TEXT NOT NULL,
11
+ content TEXT NOT NULL,
12
+ -- Where the memory came from. 'agent' = saved by the LLM via tool,
13
+ -- 'manual' = entered by a human (engram-web UI), 'auto' = derived by
14
+ -- a pipeline (future). Mirrors engram_persons.source so callers can
15
+ -- filter/render provenance consistently.
16
+ source TEXT NOT NULL DEFAULT 'agent',
17
+ -- Session that produced this memory. Nullable because UI-entered
18
+ -- memories have no originating session. No FK — sessions can be
19
+ -- purged independently and we don't want a CASCADE to vaporise the
20
+ -- memory.
21
+ source_session_id TEXT,
22
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
23
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
24
+ PRIMARY KEY (workspace_id, id),
25
+ FOREIGN KEY (workspace_id, person_id)
26
+ REFERENCES engram_persons (workspace_id, id) ON DELETE CASCADE
27
+ );
28
+
29
+ -- Hot path: "give me this person's N most recent memories" — fired
30
+ -- on every agent turn for every participant. Index covers the
31
+ -- (workspace, person) filter and the created_at desc sort.
32
+ CREATE INDEX IF NOT EXISTS idx_engram_person_memories_person_created
33
+ ON engram_person_memories (workspace_id, person_id, created_at DESC);
34
+ `;
@@ -7,6 +7,7 @@ import * as m0006 from "./0006-auth";
7
7
  import * as m0007 from "./0007-orgs";
8
8
  import * as m0008 from "./0008-trigger-metadata";
9
9
  import * as m0009 from "./0009-events-type-index";
10
+ import * as m0010 from "./0010-person-memories";
10
11
  /**
11
12
  * Schema migrations, applied in array order. Add a new file under
12
13
  * `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
@@ -24,4 +25,5 @@ export const MIGRATIONS = [
24
25
  { name: m0007.name, sql: m0007.sql },
25
26
  { name: m0008.name, sql: m0008.sql },
26
27
  { name: m0009.name, sql: m0009.sql },
28
+ { name: m0010.name, sql: m0010.sql },
27
29
  ];
package/dist/openapi.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * codes stay as-is.
19
19
  */
20
20
  import { z } from "zod";
21
- import { addMemberSchema, createOrgSchema, createWorkspaceSchema, eventBatchSchema, issueKeySchema, orgPatchSchema, personCreateSchema, personUpdateSchema, searchRequestSchema, sessionInitSchema, sessionUpdateSchema, aliasUpsertSchema, identityUpsertSchema, workspacePatchSchema, } from "./schemas";
21
+ import { addMemberSchema, createOrgSchema, createWorkspaceSchema, eventBatchSchema, issueKeySchema, orgPatchSchema, personCreateSchema, personMemoryCreateSchema, personUpdateSchema, searchRequestSchema, sessionInitSchema, sessionUpdateSchema, aliasUpsertSchema, identityUpsertSchema, workspacePatchSchema, } from "./schemas";
22
22
  /** Convert a Zod schema to a JSON Schema object for an OpenAPI component. */
23
23
  function toComponent(schema) {
24
24
  const js = z.toJSONSchema(schema);
@@ -237,6 +237,15 @@ function buildPaths() {
237
237
  "401": res("認証エラー"),
238
238
  },
239
239
  },
240
+ delete: {
241
+ summary: "person を hard-delete する。aliases / identities / memories は FK CASCADE で消える。session の participants 配列に残った id はそのまま(engram は participant id を opaque 文字列として扱う)。",
242
+ parameters: [pathParam("id", "person id。")],
243
+ responses: {
244
+ "204": res("削除完了"),
245
+ "404": res("person が見つからない"),
246
+ "401": res("認証エラー"),
247
+ },
248
+ },
240
249
  get: {
241
250
  summary: "単一の person を取得する。",
242
251
  parameters: [pathParam("id", "person id。")],
@@ -290,6 +299,18 @@ function buildPaths() {
290
299
  "401": res("認証エラー"),
291
300
  },
292
301
  },
302
+ delete: {
303
+ summary: "person の alias を削除する。name は case-insensitive。",
304
+ parameters: [
305
+ pathParam("id", "person id。"),
306
+ pathParam("name", "alias の名前(URL-encoded)。"),
307
+ ],
308
+ responses: {
309
+ "204": res("削除完了"),
310
+ "404": res("alias が見つからない"),
311
+ "401": res("認証エラー"),
312
+ },
313
+ },
293
314
  }),
294
315
  "/v1/persons/{id}/identities": tagged("Persons", {
295
316
  get: {
@@ -301,6 +322,41 @@ function buildPaths() {
301
322
  },
302
323
  },
303
324
  }),
325
+ "/v1/persons/{id}/memories": tagged("Persons", {
326
+ get: {
327
+ summary: "この person の memory(ChatGPT/Claude 風の自由文 fact)を新しい順に取得する。harness は会話開始時に participants 全員分をここから prefetch する。",
328
+ parameters: [pathParam("id", "person id。"), limitParam],
329
+ responses: {
330
+ "200": res("memory 一覧"),
331
+ "401": res("認証エラー"),
332
+ },
333
+ },
334
+ post: {
335
+ summary: "person に memory を追記する。agent からは save_person_memory ツール経由で呼ばれる。",
336
+ parameters: [pathParam("id", "person id。")],
337
+ requestBody: jsonBody("PersonMemoryCreate"),
338
+ responses: {
339
+ "201": res("追加された memory"),
340
+ "400": res("リクエストボディが不正"),
341
+ "404": res("person が見つからない"),
342
+ "401": res("認証エラー"),
343
+ },
344
+ },
345
+ }),
346
+ "/v1/persons/{id}/memories/{memoryId}": tagged("Persons", {
347
+ delete: {
348
+ summary: "memory を削除する。person 削除に伴う CASCADE で消えるので、手動削除はキュレーション用途。",
349
+ parameters: [
350
+ pathParam("id", "person id。"),
351
+ pathParam("memoryId", "memory id。"),
352
+ ],
353
+ responses: {
354
+ "204": res("削除完了"),
355
+ "404": res("memory が見つからない"),
356
+ "401": res("認証エラー"),
357
+ },
358
+ },
359
+ }),
304
360
  "/v1/aliases": tagged("Aliases", {
305
361
  get: {
306
362
  summary: "ワークスペース全体で alias 名を case-insensitive に逆引き。同名の alias を持つ複数の person を `last_used` desc で返す。`persons` map も同梱。",
@@ -673,6 +729,7 @@ export function buildOpenApiDocument() {
673
729
  EventBatch: toComponent(eventBatchSchema),
674
730
  PersonCreate: toComponent(personCreateSchema),
675
731
  PersonUpdate: toComponent(personUpdateSchema),
732
+ PersonMemoryCreate: toComponent(personMemoryCreateSchema),
676
733
  AliasUpsert: toComponent(aliasUpsertSchema),
677
734
  IdentityUpsert: toComponent(identityUpsertSchema),
678
735
  SearchRequest: toComponent(searchRequestSchema),
@@ -3,13 +3,18 @@ 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
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)
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
+ * DELETE /v1/persons/:id hard-delete (cascades to aliases / identities / memories)
10
+ * GET /v1/persons/:id fetch one person
11
+ * GET /v1/persons list / free-text search persons
12
+ * GET /v1/persons/:id/sessions sessions this person participates in / can view
13
+ * PUT /v1/persons/:id/aliases/:name upsert an alias for this person
14
+ * DELETE /v1/persons/:id/aliases/:name remove an alias (case-insensitive)
15
+ * GET /v1/persons/:id/aliases list aliases for this person (newest-used-first)
16
+ * GET /v1/persons/:id/memories list this person's memories (newest-first)
17
+ * POST /v1/persons/:id/memories append a memory
18
+ * DELETE /v1/persons/:id/memories/:mid remove a memory
14
19
  */
15
20
  export declare function personsRoutes(cfg: RouteConfig): Hono<Env>;
@@ -1,16 +1,21 @@
1
1
  import { Hono } from "hono";
2
- import { aliasUpsertSchema, parseJsonBody, personCreateSchema, personUpdateSchema, } from "../schemas";
2
+ import { aliasUpsertSchema, parseJsonBody, personCreateSchema, personMemoryCreateSchema, 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
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)
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
+ * DELETE /v1/persons/:id hard-delete (cascades to aliases / identities / memories)
10
+ * GET /v1/persons/:id fetch one person
11
+ * GET /v1/persons list / free-text search persons
12
+ * GET /v1/persons/:id/sessions sessions this person participates in / can view
13
+ * PUT /v1/persons/:id/aliases/:name upsert an alias for this person
14
+ * DELETE /v1/persons/:id/aliases/:name remove an alias (case-insensitive)
15
+ * GET /v1/persons/:id/aliases list aliases for this person (newest-used-first)
16
+ * GET /v1/persons/:id/memories list this person's memories (newest-first)
17
+ * POST /v1/persons/:id/memories append a memory
18
+ * DELETE /v1/persons/:id/memories/:mid remove a memory
14
19
  */
15
20
  export function personsRoutes(cfg) {
16
21
  const app = new Hono();
@@ -39,6 +44,13 @@ export function personsRoutes(cfg) {
39
44
  return c.json({ error: "person_not_found" }, 404);
40
45
  return c.json(p);
41
46
  });
47
+ app.delete("/persons/:id", async (c) => {
48
+ const id = c.req.param("id");
49
+ const removed = await c.var.ctx.storage.deletePerson(id);
50
+ if (!removed)
51
+ return c.json({ error: "person_not_found" }, 404);
52
+ return c.body(null, 204);
53
+ });
42
54
  app.get("/persons/:id", async (c) => {
43
55
  const id = c.req.param("id");
44
56
  const p = await c.var.ctx.storage.getPerson(id);
@@ -68,6 +80,14 @@ export function personsRoutes(cfg) {
68
80
  const aliases = await c.var.ctx.storage.listAliases(id);
69
81
  return c.json({ aliases });
70
82
  });
83
+ app.delete("/persons/:id/aliases/:name", async (c) => {
84
+ const id = c.req.param("id");
85
+ const name = c.req.param("name");
86
+ const removed = await c.var.ctx.storage.deleteAlias(id, name);
87
+ if (!removed)
88
+ return c.json({ error: "alias_not_found" }, 404);
89
+ return c.body(null, 204);
90
+ });
71
91
  app.get("/persons/:id/identities", async (c) => {
72
92
  const id = c.req.param("id");
73
93
  const identities = await c.var.ctx.storage.listIdentitiesByPerson(id);
@@ -86,5 +106,29 @@ export function personsRoutes(cfg) {
86
106
  const persons = await resolvePersonMap(c.var.ctx.storage, sessions);
87
107
  return c.json({ sessions, persons });
88
108
  });
109
+ // --- Person memories -------------------------------------------------
110
+ app.get("/persons/:id/memories", async (c) => {
111
+ const id = c.req.param("id");
112
+ const limit = clampLimit(c, cfg.defaultListLimit, cfg.maxListLimit);
113
+ const memories = await c.var.ctx.storage.listPersonMemories(id, { limit });
114
+ return c.json({ memories });
115
+ });
116
+ app.post("/persons/:id/memories", async (c) => {
117
+ const id = c.req.param("id");
118
+ const body = await parseJsonBody(c, personMemoryCreateSchema);
119
+ if (body instanceof Response)
120
+ return body;
121
+ const memory = await c.var.ctx.storage.addPersonMemory(id, body);
122
+ if (!memory)
123
+ return c.json({ error: "person_not_found" }, 404);
124
+ return c.json(memory, 201);
125
+ });
126
+ app.delete("/persons/:id/memories/:memoryId", async (c) => {
127
+ const memoryId = c.req.param("memoryId");
128
+ const removed = await c.var.ctx.storage.deletePersonMemory(memoryId);
129
+ if (!removed)
130
+ return c.json({ error: "memory_not_found" }, 404);
131
+ return c.body(null, 204);
132
+ });
89
133
  return app;
90
134
  }
package/dist/schemas.d.ts CHANGED
@@ -131,6 +131,11 @@ export declare const aliasUpsertSchema: z.ZodObject<{
131
131
  last_used: z.ZodString;
132
132
  increment: z.ZodOptional<z.ZodBoolean>;
133
133
  }, z.core.$strip>;
134
+ export declare const personMemoryCreateSchema: z.ZodObject<{
135
+ content: z.ZodString;
136
+ source: z.ZodOptional<z.ZodString>;
137
+ source_session_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
138
+ }, z.core.$strip>;
134
139
  export declare const identityUpsertSchema: z.ZodObject<{
135
140
  person_id: z.ZodString;
136
141
  service: z.ZodString;
package/dist/schemas.js CHANGED
@@ -114,6 +114,15 @@ export const aliasUpsertSchema = z.object({
114
114
  .regex(/^\d{4}-\d{2}-\d{2}/, "expected YYYY-MM-DD"),
115
115
  increment: z.boolean().optional(),
116
116
  });
117
+ // --- Person memories --------------------------------------------------
118
+ export const personMemoryCreateSchema = z.object({
119
+ // Free-form fact about the person. Min(1) rejects empty strings —
120
+ // memories that say nothing are spam noise the engram-web UI would
121
+ // have to filter anyway.
122
+ content: z.string().min(1),
123
+ source: z.string().optional(),
124
+ source_session_id: z.string().nullable().optional(),
125
+ });
117
126
  // --- Identities -------------------------------------------------------
118
127
  export const identityUpsertSchema = z.object({
119
128
  person_id: z.string().min(1),
package/dist/server.js CHANGED
@@ -83,13 +83,15 @@ export function createServer(opts) {
83
83
  sessionEvents: "GET /v1/sessions/:id/events",
84
84
  search: "POST /v1/search",
85
85
  persons: "POST/GET /v1/persons",
86
- personById: "GET/PUT/PATCH /v1/persons/:id",
86
+ personById: "GET/PUT/PATCH/DELETE /v1/persons/:id",
87
87
  personSessions: "GET /v1/persons/:id/sessions",
88
88
  personAliases: "GET /v1/persons/:id/aliases",
89
- upsertAlias: "PUT /v1/persons/:id/aliases/:name",
89
+ upsertAlias: "PUT/DELETE /v1/persons/:id/aliases/:name",
90
90
  findAliasesByName: "GET /v1/aliases?name=…",
91
91
  identityByRef: "GET/PUT /v1/identities/:ref",
92
92
  personIdentities: "GET /v1/persons/:id/identities",
93
+ personMemories: "GET/POST /v1/persons/:id/memories",
94
+ deletePersonMemory: "DELETE /v1/persons/:id/memories/:mid",
93
95
  },
94
96
  }));
95
97
  app.get("/healthz", (c) => c.json({ ok: true }));
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, SessionUpdate } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, IdentityInfo, IdentityUpsert, PersonCreate, PersonInfo, PersonMemory, PersonMemoryCreate, 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
@@ -67,6 +67,18 @@ export interface StorageAdapter {
67
67
  upsertPerson(id: string, input: PersonCreate): Promise<PersonInfo>;
68
68
  /** Update profile fields. Returns the updated record. */
69
69
  updatePerson(id: string, patch: PersonUpdate): Promise<PersonInfo | null>;
70
+ /**
71
+ * Hard-delete a person. Cascades to engram_aliases, engram_identities,
72
+ * and engram_person_memories via FK ON DELETE CASCADE. Returns `false`
73
+ * when the id doesn't exist in this workspace (idempotent — second
74
+ * call returns false but doesn't throw).
75
+ *
76
+ * Sessions are left intact: `participants` / `viewable_by` arrays may
77
+ * still reference the deleted id. Hosts that need to scrub references
78
+ * can do so via their own batch pass — engram treats a participant id
79
+ * as opaque text, not an FK.
80
+ */
81
+ deletePerson(id: string): Promise<boolean>;
70
82
  getPerson(id: string): Promise<PersonInfo | null>;
71
83
  /** Batch fetch — used by response envelopes to inline `persons` maps. */
72
84
  getPersons(ids: string[]): Promise<PersonInfo[]>;
@@ -106,6 +118,13 @@ export interface StorageAdapter {
106
118
  } & AliasUpsert): Promise<AliasInfo | null>;
107
119
  /** A person's aliases, ordered newest-used-first. */
108
120
  listAliases(personId: string): Promise<AliasInfo[]>;
121
+ /**
122
+ * Delete one alias (`person_id`, `name` case-insensitively).
123
+ * Returns `false` when no row matched — both "person has no such
124
+ * alias" and "person doesn't exist" collapse into the same boolean
125
+ * because callers (curation UI, host cleanup) treat both as no-ops.
126
+ */
127
+ deleteAlias(personId: string, name: string): Promise<boolean>;
109
128
  /**
110
129
  * Workspace-wide lookup by alias name (case-insensitive). Returns
111
130
  * every row whose `lower(name)` matches `lower(name)` across all
@@ -129,6 +148,28 @@ export interface StorageAdapter {
129
148
  getIdentityByRef(ref: string): Promise<IdentityInfo | null>;
130
149
  /** All identities for a person, ordered newest-linked-first. */
131
150
  listIdentitiesByPerson(personId: string): Promise<IdentityInfo[]>;
151
+ /**
152
+ * Append a free-form memory to a person. Returns `null` when the
153
+ * person doesn't exist — unlike aliases/identities, memories do not
154
+ * auto-create a stub person, because writing a memory for a
155
+ * non-existent id is almost always a programming mistake, not a
156
+ * race.
157
+ */
158
+ addPersonMemory(personId: string, input: PersonMemoryCreate): Promise<PersonMemory | null>;
159
+ /**
160
+ * A person's recent memories, ordered newest-first. `limit` caps the
161
+ * fetched window — the agent harness uses this on every turn so the
162
+ * adapter must keep it cheap.
163
+ */
164
+ listPersonMemories(personId: string, opts: {
165
+ limit: number;
166
+ }): Promise<PersonMemory[]>;
167
+ /**
168
+ * Remove a single memory by id. Returns `false` when the row is not
169
+ * found in this workspace (idempotent — callers can ignore the
170
+ * boolean if they don't care whether the row existed).
171
+ */
172
+ deletePersonMemory(memoryId: string): Promise<boolean>;
132
173
  }
133
174
  /**
134
175
  * Pure fold of an event log into the parts a Session needs. Used by adapters
@@ -157,3 +198,10 @@ export declare function foldEvents(row: SessionRow, events: SessionEvent[], now:
157
198
  * shape and entropy stay aligned.
158
199
  */
159
200
  export declare function newPersonId(): string;
201
+ /**
202
+ * Allocate a fresh person-memory id (`mem_` + 12 alphanumeric chars).
203
+ * Wider than person ids because memories accumulate fast — twelve
204
+ * chars buys ~62 bits of entropy, plenty for the foreseeable lifetime
205
+ * of a workspace.
206
+ */
207
+ export declare function newPersonMemoryId(): string;
package/dist/storage.js CHANGED
@@ -58,3 +58,17 @@ export function newPersonId() {
58
58
  out += PERSON_ID_ALPHA[b % PERSON_ID_ALPHA.length];
59
59
  return out;
60
60
  }
61
+ /**
62
+ * Allocate a fresh person-memory id (`mem_` + 12 alphanumeric chars).
63
+ * Wider than person ids because memories accumulate fast — twelve
64
+ * chars buys ~62 bits of entropy, plenty for the foreseeable lifetime
65
+ * of a workspace.
66
+ */
67
+ export function newPersonMemoryId() {
68
+ const buf = new Uint8Array(12);
69
+ crypto.getRandomValues(buf);
70
+ let out = "mem_";
71
+ for (const b of buf)
72
+ out += PERSON_ID_ALPHA[b % PERSON_ID_ALPHA.length];
73
+ return out;
74
+ }
package/openapi.json CHANGED
@@ -524,6 +524,32 @@
524
524
  },
525
525
  "additionalProperties": false
526
526
  },
527
+ "PersonMemoryCreate": {
528
+ "type": "object",
529
+ "properties": {
530
+ "content": {
531
+ "type": "string",
532
+ "minLength": 1
533
+ },
534
+ "source": {
535
+ "type": "string"
536
+ },
537
+ "source_session_id": {
538
+ "anyOf": [
539
+ {
540
+ "type": "string"
541
+ },
542
+ {
543
+ "type": "null"
544
+ }
545
+ ]
546
+ }
547
+ },
548
+ "required": [
549
+ "content"
550
+ ],
551
+ "additionalProperties": false
552
+ },
527
553
  "AliasUpsert": {
528
554
  "type": "object",
529
555
  "properties": {
@@ -1225,6 +1251,34 @@
1225
1251
  "Persons"
1226
1252
  ]
1227
1253
  },
1254
+ "delete": {
1255
+ "summary": "person を hard-delete する。aliases / identities / memories は FK CASCADE で消える。session の participants 配列に残った id はそのまま(engram は participant id を opaque 文字列として扱う)。",
1256
+ "parameters": [
1257
+ {
1258
+ "name": "id",
1259
+ "in": "path",
1260
+ "required": true,
1261
+ "schema": {
1262
+ "type": "string"
1263
+ },
1264
+ "description": "person id。"
1265
+ }
1266
+ ],
1267
+ "responses": {
1268
+ "204": {
1269
+ "description": "削除完了"
1270
+ },
1271
+ "401": {
1272
+ "description": "認証エラー"
1273
+ },
1274
+ "404": {
1275
+ "description": "person が見つからない"
1276
+ }
1277
+ },
1278
+ "tags": [
1279
+ "Persons"
1280
+ ]
1281
+ },
1228
1282
  "get": {
1229
1283
  "summary": "単一の person を取得する。",
1230
1284
  "parameters": [
@@ -1390,6 +1444,43 @@
1390
1444
  "tags": [
1391
1445
  "Persons"
1392
1446
  ]
1447
+ },
1448
+ "delete": {
1449
+ "summary": "person の alias を削除する。name は case-insensitive。",
1450
+ "parameters": [
1451
+ {
1452
+ "name": "id",
1453
+ "in": "path",
1454
+ "required": true,
1455
+ "schema": {
1456
+ "type": "string"
1457
+ },
1458
+ "description": "person id。"
1459
+ },
1460
+ {
1461
+ "name": "name",
1462
+ "in": "path",
1463
+ "required": true,
1464
+ "schema": {
1465
+ "type": "string"
1466
+ },
1467
+ "description": "alias の名前(URL-encoded)。"
1468
+ }
1469
+ ],
1470
+ "responses": {
1471
+ "204": {
1472
+ "description": "削除完了"
1473
+ },
1474
+ "401": {
1475
+ "description": "認証エラー"
1476
+ },
1477
+ "404": {
1478
+ "description": "alias が見つからない"
1479
+ }
1480
+ },
1481
+ "tags": [
1482
+ "Persons"
1483
+ ]
1393
1484
  }
1394
1485
  },
1395
1486
  "/v1/persons/{id}/identities": {
@@ -1419,6 +1510,123 @@
1419
1510
  ]
1420
1511
  }
1421
1512
  },
1513
+ "/v1/persons/{id}/memories": {
1514
+ "get": {
1515
+ "summary": "この person の memory(ChatGPT/Claude 風の自由文 fact)を新しい順に取得する。harness は会話開始時に participants 全員分をここから prefetch する。",
1516
+ "parameters": [
1517
+ {
1518
+ "name": "id",
1519
+ "in": "path",
1520
+ "required": true,
1521
+ "schema": {
1522
+ "type": "string"
1523
+ },
1524
+ "description": "person id。"
1525
+ },
1526
+ {
1527
+ "name": "limit",
1528
+ "in": "query",
1529
+ "required": false,
1530
+ "schema": {
1531
+ "type": "integer",
1532
+ "minimum": 1
1533
+ },
1534
+ "description": "ページサイズ。サーバー側で上限が適用される。"
1535
+ }
1536
+ ],
1537
+ "responses": {
1538
+ "200": {
1539
+ "description": "memory 一覧"
1540
+ },
1541
+ "401": {
1542
+ "description": "認証エラー"
1543
+ }
1544
+ },
1545
+ "tags": [
1546
+ "Persons"
1547
+ ]
1548
+ },
1549
+ "post": {
1550
+ "summary": "person に memory を追記する。agent からは save_person_memory ツール経由で呼ばれる。",
1551
+ "parameters": [
1552
+ {
1553
+ "name": "id",
1554
+ "in": "path",
1555
+ "required": true,
1556
+ "schema": {
1557
+ "type": "string"
1558
+ },
1559
+ "description": "person id。"
1560
+ }
1561
+ ],
1562
+ "requestBody": {
1563
+ "required": true,
1564
+ "content": {
1565
+ "application/json": {
1566
+ "schema": {
1567
+ "$ref": "#/components/schemas/PersonMemoryCreate"
1568
+ }
1569
+ }
1570
+ }
1571
+ },
1572
+ "responses": {
1573
+ "201": {
1574
+ "description": "追加された memory"
1575
+ },
1576
+ "400": {
1577
+ "description": "リクエストボディが不正"
1578
+ },
1579
+ "401": {
1580
+ "description": "認証エラー"
1581
+ },
1582
+ "404": {
1583
+ "description": "person が見つからない"
1584
+ }
1585
+ },
1586
+ "tags": [
1587
+ "Persons"
1588
+ ]
1589
+ }
1590
+ },
1591
+ "/v1/persons/{id}/memories/{memoryId}": {
1592
+ "delete": {
1593
+ "summary": "memory を削除する。person 削除に伴う CASCADE で消えるので、手動削除はキュレーション用途。",
1594
+ "parameters": [
1595
+ {
1596
+ "name": "id",
1597
+ "in": "path",
1598
+ "required": true,
1599
+ "schema": {
1600
+ "type": "string"
1601
+ },
1602
+ "description": "person id。"
1603
+ },
1604
+ {
1605
+ "name": "memoryId",
1606
+ "in": "path",
1607
+ "required": true,
1608
+ "schema": {
1609
+ "type": "string"
1610
+ },
1611
+ "description": "memory id。"
1612
+ }
1613
+ ],
1614
+ "responses": {
1615
+ "204": {
1616
+ "description": "削除完了"
1617
+ },
1618
+ "401": {
1619
+ "description": "認証エラー"
1620
+ },
1621
+ "404": {
1622
+ "description": "memory が見つからない"
1623
+ }
1624
+ },
1625
+ "tags": [
1626
+ "Persons"
1627
+ ]
1628
+ }
1629
+ },
1422
1630
  "/v1/aliases": {
1423
1631
  "get": {
1424
1632
  "summary": "ワークスペース全体で alias 名を case-insensitive に逆引き。同名の alias を持つ複数の person を `last_used` desc で返す。`persons` map も同梱。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexis-ai/engram-server",
3
- "version": "0.16.0",
3
+ "version": "0.17.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.3.0",
53
- "@hexis-ai/engram-sdk": "^0.16.0",
53
+ "@hexis-ai/engram-sdk": "^0.17.0",
54
54
  "better-auth": "^1.6.11",
55
55
  "hono": "^4.6.0",
56
56
  "pg": "^8.13.0",