@hexis-ai/engram-server 0.1.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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/adapters/memory.d.ts +20 -0
- package/dist/adapters/memory.js +49 -0
- package/dist/adapters/postgres.d.ts +38 -0
- package/dist/adapters/postgres.js +116 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/server.d.ts +35 -0
- package/dist/server.js +93 -0
- package/dist/storage.d.ts +35 -0
- package/dist/storage.js +26 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hexis ltd.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @hexis-ai/engram-server
|
|
2
|
+
|
|
3
|
+
Hono-based HTTP server for engram with pluggable storage.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @hexis-ai/engram-server
|
|
9
|
+
# optional, for the postgres adapter:
|
|
10
|
+
npm i postgres
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createServer, InMemoryAdapter } from "@hexis-ai/engram-server";
|
|
17
|
+
|
|
18
|
+
const adapter = new InMemoryAdapter();
|
|
19
|
+
const app = createServer({
|
|
20
|
+
auth: (apiKey) => apiKey === process.env.ENGRAM_API_KEY
|
|
21
|
+
? { workspaceId: "default", storage: adapter }
|
|
22
|
+
: null,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export default { port: 3100, fetch: app.fetch };
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## With Postgres
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import postgres from "postgres";
|
|
32
|
+
import { createServer, PostgresAdapter } from "@hexis-ai/engram-server";
|
|
33
|
+
|
|
34
|
+
const sql = postgres(process.env.DATABASE_URL!);
|
|
35
|
+
|
|
36
|
+
// Multi-tenant: one adapter instance per workspace, scoped by workspaceId.
|
|
37
|
+
function adapterFor(workspaceId: string) {
|
|
38
|
+
return new PostgresAdapter({ workspaceId, sql });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await new PostgresAdapter({ workspaceId: "_init", sql }).ensureSchema();
|
|
42
|
+
|
|
43
|
+
const app = createServer({
|
|
44
|
+
auth: async (apiKey) => {
|
|
45
|
+
const ws = await resolveApiKey(apiKey); // your own keying
|
|
46
|
+
if (!ws) return null;
|
|
47
|
+
return { workspaceId: ws, storage: adapterFor(ws) };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Routes
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
POST /v1/sessions { id?, title?, channel?, participants? } → { id }
|
|
56
|
+
POST /v1/sessions/:id/events { events: SessionEvent[] } → 204
|
|
57
|
+
GET /v1/sessions/:id → Session
|
|
58
|
+
GET /v1/sessions?limit&channel → Session[]
|
|
59
|
+
POST /v1/search { query, options? } → { results }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Storage adapter contract
|
|
63
|
+
|
|
64
|
+
Implement `StorageAdapter` to plug in your own backend (SQLite, Redis, Dynamo, etc.):
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
interface StorageAdapter {
|
|
68
|
+
createSession(init): Promise<void>;
|
|
69
|
+
appendEvents(sessionId, events): Promise<void>;
|
|
70
|
+
getSession(sessionId): Promise<Session | null>;
|
|
71
|
+
listSessions({ limit, channel? }): Promise<Session[]>;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Events are append-only and idempotent on `(sessionId, seq)`. Sessions materialize via the `foldEvents()` helper.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Session } from "@hexis-ai/engram-core";
|
|
2
|
+
import type { SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
|
|
3
|
+
import { type StorageAdapter } from "../storage";
|
|
4
|
+
/**
|
|
5
|
+
* In-process storage adapter for tests, dev, and small single-node deploys.
|
|
6
|
+
* Idempotency: events keyed by (sessionId, seq) — duplicates overwrite by seq.
|
|
7
|
+
*/
|
|
8
|
+
export declare class InMemoryAdapter implements StorageAdapter {
|
|
9
|
+
private readonly sessions;
|
|
10
|
+
createSession(init: SessionInit & {
|
|
11
|
+
id: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
appendEvents(sessionId: string, events: SessionEvent[]): Promise<void>;
|
|
15
|
+
getSession(sessionId: string): Promise<Session | null>;
|
|
16
|
+
listSessions(opts: {
|
|
17
|
+
limit: number;
|
|
18
|
+
channel?: string;
|
|
19
|
+
}): Promise<Session[]>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { foldEvents } from "../storage";
|
|
2
|
+
/**
|
|
3
|
+
* In-process storage adapter for tests, dev, and small single-node deploys.
|
|
4
|
+
* Idempotency: events keyed by (sessionId, seq) — duplicates overwrite by seq.
|
|
5
|
+
*/
|
|
6
|
+
export class InMemoryAdapter {
|
|
7
|
+
sessions = new Map();
|
|
8
|
+
async createSession(init) {
|
|
9
|
+
if (this.sessions.has(init.id))
|
|
10
|
+
return;
|
|
11
|
+
this.sessions.set(init.id, {
|
|
12
|
+
row: {
|
|
13
|
+
id: init.id,
|
|
14
|
+
...(init.title ? { title: init.title } : {}),
|
|
15
|
+
...(init.channel ? { channel: init.channel } : {}),
|
|
16
|
+
participants: init.participants ?? [],
|
|
17
|
+
createdAt: init.createdAt,
|
|
18
|
+
},
|
|
19
|
+
events: new Map(),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async appendEvents(sessionId, events) {
|
|
23
|
+
const s = this.sessions.get(sessionId);
|
|
24
|
+
if (!s)
|
|
25
|
+
throw new Error(`session not found: ${sessionId}`);
|
|
26
|
+
for (const ev of events)
|
|
27
|
+
s.events.set(ev.seq, ev);
|
|
28
|
+
}
|
|
29
|
+
async getSession(sessionId) {
|
|
30
|
+
const s = this.sessions.get(sessionId);
|
|
31
|
+
if (!s)
|
|
32
|
+
return null;
|
|
33
|
+
return foldEvents(s.row, [...s.events.values()], new Date());
|
|
34
|
+
}
|
|
35
|
+
async listSessions(opts) {
|
|
36
|
+
const now = new Date();
|
|
37
|
+
const all = [];
|
|
38
|
+
for (const stored of this.sessions.values()) {
|
|
39
|
+
if (opts.channel && stored.row.channel !== opts.channel)
|
|
40
|
+
continue;
|
|
41
|
+
all.push({
|
|
42
|
+
s: foldEvents(stored.row, [...stored.events.values()], now),
|
|
43
|
+
createdAt: stored.row.createdAt,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
47
|
+
return all.slice(0, opts.limit).map((x) => x.s);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Session } from "@hexis-ai/engram-core";
|
|
2
|
+
import type { SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
|
|
3
|
+
import { type StorageAdapter } from "../storage";
|
|
4
|
+
/**
|
|
5
|
+
* Minimal subset of `postgres` driver's tagged-template surface that this
|
|
6
|
+
* adapter relies on. Declared structurally so the package does not hard-import
|
|
7
|
+
* the `postgres` module (it is a peer dep) and tests can supply a fake.
|
|
8
|
+
*/
|
|
9
|
+
export interface SqlClient {
|
|
10
|
+
<T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
|
|
11
|
+
}
|
|
12
|
+
export interface PostgresAdapterOptions {
|
|
13
|
+
/** Workspace scope baked into every row. Required for multi-tenant isolation. */
|
|
14
|
+
workspaceId: string;
|
|
15
|
+
sql: SqlClient;
|
|
16
|
+
}
|
|
17
|
+
export declare class PostgresAdapter implements StorageAdapter {
|
|
18
|
+
private readonly workspaceId;
|
|
19
|
+
private readonly sql;
|
|
20
|
+
constructor(opts: PostgresAdapterOptions);
|
|
21
|
+
/**
|
|
22
|
+
* Create the schema. Call once at boot. Safe to invoke repeatedly.
|
|
23
|
+
* Postgres pg-tag template clients accept multi-statement strings via
|
|
24
|
+
* `sql.unsafe()` in the real driver; for portability we issue them
|
|
25
|
+
* separately so any tagged-template impl works.
|
|
26
|
+
*/
|
|
27
|
+
ensureSchema(): Promise<void>;
|
|
28
|
+
createSession(init: SessionInit & {
|
|
29
|
+
id: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
appendEvents(sessionId: string, events: SessionEvent[]): Promise<void>;
|
|
33
|
+
getSession(sessionId: string): Promise<Session | null>;
|
|
34
|
+
listSessions(opts: {
|
|
35
|
+
limit: number;
|
|
36
|
+
channel?: string;
|
|
37
|
+
}): Promise<Session[]>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { foldEvents } from "../storage";
|
|
2
|
+
const SCHEMA_SQL = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS engram_sessions (
|
|
4
|
+
workspace_id TEXT NOT NULL,
|
|
5
|
+
id TEXT NOT NULL,
|
|
6
|
+
title TEXT,
|
|
7
|
+
channel TEXT,
|
|
8
|
+
participants TEXT[] NOT NULL DEFAULT '{}',
|
|
9
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
10
|
+
PRIMARY KEY (workspace_id, id)
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS engram_events (
|
|
14
|
+
workspace_id TEXT NOT NULL,
|
|
15
|
+
session_id TEXT NOT NULL,
|
|
16
|
+
seq INTEGER NOT NULL,
|
|
17
|
+
type TEXT NOT NULL,
|
|
18
|
+
at TIMESTAMPTZ NOT NULL,
|
|
19
|
+
payload JSONB NOT NULL,
|
|
20
|
+
PRIMARY KEY (workspace_id, session_id, seq),
|
|
21
|
+
FOREIGN KEY (workspace_id, session_id)
|
|
22
|
+
REFERENCES engram_sessions(workspace_id, id) ON DELETE CASCADE
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_created
|
|
26
|
+
ON engram_sessions (workspace_id, created_at DESC);
|
|
27
|
+
`;
|
|
28
|
+
export class PostgresAdapter {
|
|
29
|
+
workspaceId;
|
|
30
|
+
sql;
|
|
31
|
+
constructor(opts) {
|
|
32
|
+
this.workspaceId = opts.workspaceId;
|
|
33
|
+
this.sql = opts.sql;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create the schema. Call once at boot. Safe to invoke repeatedly.
|
|
37
|
+
* Postgres pg-tag template clients accept multi-statement strings via
|
|
38
|
+
* `sql.unsafe()` in the real driver; for portability we issue them
|
|
39
|
+
* separately so any tagged-template impl works.
|
|
40
|
+
*/
|
|
41
|
+
async ensureSchema() {
|
|
42
|
+
const stmts = SCHEMA_SQL.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean);
|
|
43
|
+
for (const stmt of stmts) {
|
|
44
|
+
// Tagged-template invocation with no interpolations.
|
|
45
|
+
await this.sql([stmt + ";"]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async createSession(init) {
|
|
49
|
+
await this.sql `
|
|
50
|
+
INSERT INTO engram_sessions (workspace_id, id, title, channel, participants, created_at)
|
|
51
|
+
VALUES (
|
|
52
|
+
${this.workspaceId},
|
|
53
|
+
${init.id},
|
|
54
|
+
${init.title ?? null},
|
|
55
|
+
${init.channel ?? null},
|
|
56
|
+
${init.participants ?? []},
|
|
57
|
+
${init.createdAt}
|
|
58
|
+
)
|
|
59
|
+
ON CONFLICT (workspace_id, id) DO NOTHING
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
async appendEvents(sessionId, events) {
|
|
63
|
+
if (events.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
for (const ev of events) {
|
|
66
|
+
await this.sql `
|
|
67
|
+
INSERT INTO engram_events (workspace_id, session_id, seq, type, at, payload)
|
|
68
|
+
VALUES (
|
|
69
|
+
${this.workspaceId},
|
|
70
|
+
${sessionId},
|
|
71
|
+
${ev.seq},
|
|
72
|
+
${ev.type},
|
|
73
|
+
${ev.at},
|
|
74
|
+
${JSON.stringify(ev)}::jsonb
|
|
75
|
+
)
|
|
76
|
+
ON CONFLICT (workspace_id, session_id, seq) DO NOTHING
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async getSession(sessionId) {
|
|
81
|
+
const rows = await this.sql `
|
|
82
|
+
SELECT id, title, channel, participants, created_at
|
|
83
|
+
FROM engram_sessions
|
|
84
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
85
|
+
LIMIT 1
|
|
86
|
+
`;
|
|
87
|
+
if (rows.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
const r = rows[0];
|
|
90
|
+
const events = await this.sql `
|
|
91
|
+
SELECT payload FROM engram_events
|
|
92
|
+
WHERE workspace_id = ${this.workspaceId} AND session_id = ${sessionId}
|
|
93
|
+
ORDER BY seq
|
|
94
|
+
`;
|
|
95
|
+
const row = {
|
|
96
|
+
id: r.id,
|
|
97
|
+
...(r.title ? { title: r.title } : {}),
|
|
98
|
+
...(r.channel ? { channel: r.channel } : {}),
|
|
99
|
+
participants: r.participants,
|
|
100
|
+
createdAt: typeof r.created_at === "string" ? r.created_at : r.created_at.toISOString(),
|
|
101
|
+
};
|
|
102
|
+
return foldEvents(row, events.map((e) => e.payload), new Date());
|
|
103
|
+
}
|
|
104
|
+
async listSessions(opts) {
|
|
105
|
+
const channelFilter = opts.channel ?? null;
|
|
106
|
+
const rows = await this.sql `
|
|
107
|
+
SELECT id FROM engram_sessions
|
|
108
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
109
|
+
AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
|
|
110
|
+
ORDER BY created_at DESC
|
|
111
|
+
LIMIT ${opts.limit}
|
|
112
|
+
`;
|
|
113
|
+
const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
|
|
114
|
+
return sessions.filter((s) => s !== null);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createServer, type CreateServerOptions, type AuthResolver, type WorkspaceContext, } from "./server";
|
|
2
|
+
export { foldEvents, type StorageAdapter, type SessionRow, } from "./storage";
|
|
3
|
+
export { InMemoryAdapter } from "./adapters/memory";
|
|
4
|
+
export { PostgresAdapter, type PostgresAdapterOptions, type SqlClient, } from "./adapters/postgres";
|
package/dist/index.js
ADDED
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { StorageAdapter } from "./storage";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve an API key (raw `Authorization: Bearer <key>` token) into a
|
|
5
|
+
* workspace context. Throwing or returning null short-circuits to 401.
|
|
6
|
+
*/
|
|
7
|
+
export interface AuthResolver {
|
|
8
|
+
(apiKey: string): Promise<WorkspaceContext | null> | WorkspaceContext | null;
|
|
9
|
+
}
|
|
10
|
+
export interface WorkspaceContext {
|
|
11
|
+
workspaceId: string;
|
|
12
|
+
/** The storage adapter scoped to this workspace. */
|
|
13
|
+
storage: StorageAdapter;
|
|
14
|
+
}
|
|
15
|
+
export interface CreateServerOptions {
|
|
16
|
+
/** Resolves Bearer tokens into workspace contexts. */
|
|
17
|
+
auth: AuthResolver;
|
|
18
|
+
/**
|
|
19
|
+
* Generates session ids. Defaults to `crypto.randomUUID()`.
|
|
20
|
+
* Provide a custom function for deterministic ids in tests.
|
|
21
|
+
*/
|
|
22
|
+
newSessionId?: () => string;
|
|
23
|
+
/**
|
|
24
|
+
* Clamps for `GET /v1/sessions?limit=…`. Defaults: 100 / 500.
|
|
25
|
+
*/
|
|
26
|
+
defaultListLimit?: number;
|
|
27
|
+
maxListLimit?: number;
|
|
28
|
+
}
|
|
29
|
+
interface Env {
|
|
30
|
+
Variables: {
|
|
31
|
+
ctx: WorkspaceContext;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export declare function createServer(opts: CreateServerOptions): Hono<Env>;
|
|
35
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { buildIndex, search, } from "@hexis-ai/engram-core";
|
|
3
|
+
export function createServer(opts) {
|
|
4
|
+
const newId = opts.newSessionId ?? (() => crypto.randomUUID());
|
|
5
|
+
const defaultLimit = opts.defaultListLimit ?? 100;
|
|
6
|
+
const maxLimit = opts.maxListLimit ?? 500;
|
|
7
|
+
const app = new Hono();
|
|
8
|
+
app.use("/v1/*", async (c, next) => {
|
|
9
|
+
const auth = c.req.header("authorization");
|
|
10
|
+
const m = auth?.match(/^Bearer\s+(.+)$/i);
|
|
11
|
+
if (!m)
|
|
12
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
13
|
+
const ctx = await opts.auth(m[1]);
|
|
14
|
+
if (!ctx)
|
|
15
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
16
|
+
c.set("ctx", ctx);
|
|
17
|
+
await next();
|
|
18
|
+
});
|
|
19
|
+
app.post("/v1/sessions", async (c) => {
|
|
20
|
+
const body = (await c.req.json().catch(() => null));
|
|
21
|
+
if (body === null)
|
|
22
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
23
|
+
const id = body.id ?? newId();
|
|
24
|
+
const createdAt = new Date().toISOString();
|
|
25
|
+
await c.var.ctx.storage.createSession({
|
|
26
|
+
...body,
|
|
27
|
+
id,
|
|
28
|
+
createdAt,
|
|
29
|
+
});
|
|
30
|
+
return c.json({ id });
|
|
31
|
+
});
|
|
32
|
+
app.post("/v1/sessions/:id/events", async (c) => {
|
|
33
|
+
const id = c.req.param("id");
|
|
34
|
+
const body = (await c.req.json().catch(() => null));
|
|
35
|
+
if (!body || !Array.isArray(body.events)) {
|
|
36
|
+
return c.json({ error: "events_array_required" }, 400);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await c.var.ctx.storage.appendEvents(id, body.events);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return c.json({ error: "session_not_found", message: e.message }, 404);
|
|
43
|
+
}
|
|
44
|
+
return c.body(null, 204);
|
|
45
|
+
});
|
|
46
|
+
app.get("/v1/sessions/:id", async (c) => {
|
|
47
|
+
const id = c.req.param("id");
|
|
48
|
+
const s = await c.var.ctx.storage.getSession(id);
|
|
49
|
+
if (!s)
|
|
50
|
+
return c.json({ error: "session_not_found" }, 404);
|
|
51
|
+
return c.json(s);
|
|
52
|
+
});
|
|
53
|
+
app.get("/v1/sessions", async (c) => {
|
|
54
|
+
const limit = clampLimit(c, defaultLimit, maxLimit);
|
|
55
|
+
const channel = c.req.query("channel") || undefined;
|
|
56
|
+
const sessions = await c.var.ctx.storage.listSessions({ limit, channel });
|
|
57
|
+
return c.json(sessions);
|
|
58
|
+
});
|
|
59
|
+
app.post("/v1/search", async (c) => {
|
|
60
|
+
const body = (await c.req.json().catch(() => null));
|
|
61
|
+
if (!body || !body.query)
|
|
62
|
+
return c.json({ error: "query_required" }, 400);
|
|
63
|
+
const corpus = await c.var.ctx.storage.listSessions({ limit: maxLimit });
|
|
64
|
+
let queryQuery;
|
|
65
|
+
if ("sessionId" in body.query) {
|
|
66
|
+
const q = await c.var.ctx.storage.getSession(body.query.sessionId);
|
|
67
|
+
if (!q)
|
|
68
|
+
return c.json({ error: "query_session_not_found" }, 404);
|
|
69
|
+
queryQuery = q;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
queryQuery = body.query.session;
|
|
73
|
+
}
|
|
74
|
+
const index = buildIndex(corpus);
|
|
75
|
+
const opts = body.options ?? {};
|
|
76
|
+
const queryParticipants = opts.queryParticipants ?? queryQuery.participants ?? [];
|
|
77
|
+
const results = search(queryQuery.steps, index, {
|
|
78
|
+
...opts,
|
|
79
|
+
queryParticipants,
|
|
80
|
+
});
|
|
81
|
+
return c.json({ results });
|
|
82
|
+
});
|
|
83
|
+
return app;
|
|
84
|
+
}
|
|
85
|
+
function clampLimit(c, def, max) {
|
|
86
|
+
const raw = c.req.query("limit");
|
|
87
|
+
if (!raw)
|
|
88
|
+
return def;
|
|
89
|
+
const n = parseInt(raw, 10);
|
|
90
|
+
if (isNaN(n) || n < 1)
|
|
91
|
+
return def;
|
|
92
|
+
return Math.min(n, max);
|
|
93
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Session } from "@hexis-ai/engram-core";
|
|
2
|
+
import type { SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
|
|
3
|
+
/**
|
|
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).
|
|
7
|
+
*/
|
|
8
|
+
export interface StorageAdapter {
|
|
9
|
+
/** Create a session row. Returns the assigned id. */
|
|
10
|
+
createSession(init: SessionInit & {
|
|
11
|
+
id: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
/** Append events for a session in batch. Idempotent on (sessionId, seq). */
|
|
15
|
+
appendEvents(sessionId: string, events: SessionEvent[]): Promise<void>;
|
|
16
|
+
/** Materialize a session (events folded into Session shape). */
|
|
17
|
+
getSession(sessionId: string): Promise<Session | null>;
|
|
18
|
+
/** List recent sessions. */
|
|
19
|
+
listSessions(opts: {
|
|
20
|
+
limit: number;
|
|
21
|
+
channel?: string;
|
|
22
|
+
}): Promise<Session[]>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Pure fold of an event log into the parts a Session needs. Used by adapters
|
|
26
|
+
* after they retrieve raw events to assemble the materialized view.
|
|
27
|
+
*/
|
|
28
|
+
export interface SessionRow {
|
|
29
|
+
id: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
channel?: string;
|
|
32
|
+
participants: string[];
|
|
33
|
+
createdAt: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function foldEvents(row: SessionRow, events: SessionEvent[], now: Date): Session;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function foldEvents(row, events, now) {
|
|
2
|
+
const sorted = [...events].sort((a, b) => a.seq - b.seq);
|
|
3
|
+
const steps = [];
|
|
4
|
+
const participants = new Set(row.participants);
|
|
5
|
+
let title = row.title;
|
|
6
|
+
for (const ev of sorted) {
|
|
7
|
+
if (ev.type === "step") {
|
|
8
|
+
steps.push({ tool: ev.tool, resources: [...ev.resources] });
|
|
9
|
+
}
|
|
10
|
+
else if (ev.type === "participant") {
|
|
11
|
+
participants.add(ev.identityRef);
|
|
12
|
+
}
|
|
13
|
+
else if (ev.type === "title") {
|
|
14
|
+
title = ev.title;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const created = new Date(row.createdAt).getTime();
|
|
18
|
+
const daysAgo = Math.max(0, (now.getTime() - created) / 86_400_000);
|
|
19
|
+
return {
|
|
20
|
+
id: row.id,
|
|
21
|
+
...(title ? { title } : {}),
|
|
22
|
+
steps,
|
|
23
|
+
daysAgo,
|
|
24
|
+
...(participants.size > 0 ? { participants: [...participants] } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hexis-ai/engram-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Engram server: ingest agent session events, persist via a pluggable adapter, expose search.",
|
|
5
|
+
"keywords": ["engram", "agents", "search", "hono", "postgres", "server"],
|
|
6
|
+
"homepage": "https://github.com/hexis-ai/engram#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/hexis-ai/engram.git",
|
|
10
|
+
"directory": "packages/server"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/hexis-ai/engram/issues",
|
|
13
|
+
"author": "hexis ltd.",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./adapters/memory": {
|
|
24
|
+
"types": "./dist/adapters/memory.d.ts",
|
|
25
|
+
"default": "./dist/adapters/memory.js"
|
|
26
|
+
},
|
|
27
|
+
"./adapters/postgres": {
|
|
28
|
+
"types": "./dist/adapters/postgres.d.ts",
|
|
29
|
+
"default": "./dist/adapters/postgres.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
37
|
+
"prepack": "bun run build",
|
|
38
|
+
"pack": "bun run build && bun pm pack",
|
|
39
|
+
"test": "bun test",
|
|
40
|
+
"type-check": "tsc --noEmit",
|
|
41
|
+
"dev": "bun --hot src/dev.ts"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@hexis-ai/engram-core": "workspace:*",
|
|
45
|
+
"@hexis-ai/engram-sdk": "workspace:*",
|
|
46
|
+
"hono": "^4.6.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"postgres": "^3.4.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"postgres": { "optional": true }
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|