@pentatonic-ai/ai-agent-sdk 0.7.5 → 0.7.7
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/package.json +4 -2
- package/packages/memory/src/__tests__/engine-layers.test.js +62 -0
- package/packages/memory/src/__tests__/engine.test.js +214 -0
- package/packages/memory/src/engine-layers.js +72 -0
- package/packages/memory/src/engine.js +198 -0
- package/packages/memory-engine/compat/server.py +9 -2
- package/packages/memory-engine/docker-compose.test.yml +6 -0
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +23 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"./memory/openclaw": "./packages/memory/src/openclaw/index.js",
|
|
16
16
|
"./doctor": "./packages/doctor/src/index.js",
|
|
17
17
|
"./memory/hosted": "./packages/memory/src/hosted.js",
|
|
18
|
-
"./memory/corpus": "./packages/memory/src/corpus/index.js"
|
|
18
|
+
"./memory/corpus": "./packages/memory/src/corpus/index.js",
|
|
19
|
+
"./memory/engine": "./packages/memory/src/engine.js",
|
|
20
|
+
"./memory/engine-layers": "./packages/memory/src/engine-layers.js"
|
|
19
21
|
},
|
|
20
22
|
"bin": {
|
|
21
23
|
"ai-agent-sdk": "./bin/cli.js"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
STATIC_LAYER_TYPES,
|
|
4
|
+
buildStaticLayers,
|
|
5
|
+
layerIdToType,
|
|
6
|
+
} from "../engine-layers.js";
|
|
7
|
+
|
|
8
|
+
describe("engine-layers", () => {
|
|
9
|
+
it("STATIC_LAYER_TYPES is the documented 4-layer surface", () => {
|
|
10
|
+
expect(STATIC_LAYER_TYPES).toEqual([
|
|
11
|
+
"episodic",
|
|
12
|
+
"semantic",
|
|
13
|
+
"procedural",
|
|
14
|
+
"working",
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("buildStaticLayers", () => {
|
|
19
|
+
it("returns one row per layer type with stable id shape", () => {
|
|
20
|
+
const rows = buildStaticLayers("acme");
|
|
21
|
+
expect(rows.map((r) => r.id)).toEqual([
|
|
22
|
+
"ml_acme_episodic",
|
|
23
|
+
"ml_acme_semantic",
|
|
24
|
+
"ml_acme_procedural",
|
|
25
|
+
"ml_acme_working",
|
|
26
|
+
]);
|
|
27
|
+
for (const r of rows) {
|
|
28
|
+
expect(r.client_id).toBe("acme");
|
|
29
|
+
expect(r.layer_type).toBe(r.name);
|
|
30
|
+
expect(r.is_active).toBe(true);
|
|
31
|
+
expect(r.memory_count).toBe(null);
|
|
32
|
+
expect(r.capacity).toBe(null);
|
|
33
|
+
expect(r.decay_policy).toBe(null);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("created_at is install-time stable (epoch) so dashboards don't churn", () => {
|
|
38
|
+
const a = buildStaticLayers("acme");
|
|
39
|
+
const b = buildStaticLayers("acme");
|
|
40
|
+
expect(a[0].created_at).toBe(b[0].created_at);
|
|
41
|
+
expect(a[0].created_at).toBe(new Date(0).toISOString());
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("layerIdToType", () => {
|
|
46
|
+
it("extracts the type suffix", () => {
|
|
47
|
+
expect(layerIdToType("ml_acme_episodic")).toBe("episodic");
|
|
48
|
+
expect(layerIdToType("ml_acme_semantic")).toBe("semantic");
|
|
49
|
+
expect(layerIdToType("ml_acme_procedural")).toBe("procedural");
|
|
50
|
+
expect(layerIdToType("ml_acme_working")).toBe("working");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to episodic for unknown / malformed ids", () => {
|
|
54
|
+
expect(layerIdToType("ml_acme_other")).toBe("episodic");
|
|
55
|
+
expect(layerIdToType("garbage")).toBe("episodic");
|
|
56
|
+
expect(layerIdToType("")).toBe("episodic");
|
|
57
|
+
expect(layerIdToType(null)).toBe("episodic");
|
|
58
|
+
expect(layerIdToType(undefined)).toBe("episodic");
|
|
59
|
+
expect(layerIdToType(42)).toBe("episodic");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
fetchEngine,
|
|
4
|
+
engineStore,
|
|
5
|
+
engineSearch,
|
|
6
|
+
engineForget,
|
|
7
|
+
DEFAULT_ENGINE_URL,
|
|
8
|
+
} from "../engine.js";
|
|
9
|
+
|
|
10
|
+
describe("engine HTTP client", () => {
|
|
11
|
+
let originalFetch;
|
|
12
|
+
let calls;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
originalFetch = global.fetch;
|
|
16
|
+
calls = [];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
global.fetch = originalFetch;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function mockOk(responseBody) {
|
|
24
|
+
global.fetch = jest.fn(async (url, init) => {
|
|
25
|
+
calls.push({ url, init });
|
|
26
|
+
return {
|
|
27
|
+
ok: true,
|
|
28
|
+
status: 200,
|
|
29
|
+
json: async () => responseBody,
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mockHttp(status, text) {
|
|
35
|
+
global.fetch = jest.fn(async (url, init) => {
|
|
36
|
+
calls.push({ url, init });
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
status,
|
|
40
|
+
text: async () => text,
|
|
41
|
+
json: async () => ({}),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mockNetworkErr(message) {
|
|
47
|
+
global.fetch = jest.fn(async () => {
|
|
48
|
+
throw new Error(message);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("fetchEngine", () => {
|
|
53
|
+
it("uses provided engineUrl + path verbatim", async () => {
|
|
54
|
+
mockOk({ ok: true });
|
|
55
|
+
await fetchEngine("https://x.example", "/store", { a: 1 });
|
|
56
|
+
expect(calls[0].url).toBe("https://x.example/store");
|
|
57
|
+
expect(calls[0].init.method).toBe("POST");
|
|
58
|
+
expect(calls[0].init.headers["content-type"]).toBe("application/json");
|
|
59
|
+
expect(JSON.parse(calls[0].init.body)).toEqual({ a: 1 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls back to DEFAULT_ENGINE_URL when engineUrl is empty", async () => {
|
|
63
|
+
mockOk({ ok: true });
|
|
64
|
+
await fetchEngine("", "/health", {});
|
|
65
|
+
expect(calls[0].url).toBe(`${DEFAULT_ENGINE_URL}/health`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("throws engine_<status> with detail on HTTP non-2xx", async () => {
|
|
69
|
+
mockHttp(503, "engine down");
|
|
70
|
+
await expect(fetchEngine("https://x", "/store", {})).rejects.toMatchObject({
|
|
71
|
+
message: "engine_503",
|
|
72
|
+
detail: "engine down",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("throws engine_network on transport failure", async () => {
|
|
77
|
+
mockNetworkErr("ECONNREFUSED");
|
|
78
|
+
await expect(fetchEngine("https://x", "/store", {})).rejects.toThrow(
|
|
79
|
+
/engine_network: ECONNREFUSED/
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("engineStore", () => {
|
|
85
|
+
it("builds canonical /store body with arena=clientId", async () => {
|
|
86
|
+
mockOk({ id: "abc", content: "hello", layerId: "ml_acme_episodic" });
|
|
87
|
+
await engineStore("https://e", {
|
|
88
|
+
clientId: "acme",
|
|
89
|
+
content: "hello",
|
|
90
|
+
layerType: "episodic",
|
|
91
|
+
actorUserId: "u-1",
|
|
92
|
+
metadata: { kind: "note" },
|
|
93
|
+
});
|
|
94
|
+
const body = JSON.parse(calls[0].init.body);
|
|
95
|
+
expect(calls[0].url).toBe("https://e/store");
|
|
96
|
+
expect(body).toEqual({
|
|
97
|
+
content: "hello",
|
|
98
|
+
metadata: {
|
|
99
|
+
kind: "note",
|
|
100
|
+
arena: "acme",
|
|
101
|
+
layer_type: "episodic",
|
|
102
|
+
actor_user_id: "u-1",
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("omits layer_type and actor_user_id when not provided", async () => {
|
|
108
|
+
mockOk({ id: "x", content: "x", layerId: "ml_acme_episodic" });
|
|
109
|
+
await engineStore("https://e", { clientId: "acme", content: "x" });
|
|
110
|
+
const body = JSON.parse(calls[0].init.body);
|
|
111
|
+
expect(body.metadata).toEqual({ arena: "acme" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does NOT let caller override arena via metadata", async () => {
|
|
115
|
+
mockOk({ id: "x", content: "x", layerId: "ml_acme_episodic" });
|
|
116
|
+
await engineStore("https://e", {
|
|
117
|
+
clientId: "acme",
|
|
118
|
+
content: "x",
|
|
119
|
+
// attempted hostile arena spoof:
|
|
120
|
+
metadata: { arena: "tenant-b" },
|
|
121
|
+
});
|
|
122
|
+
const body = JSON.parse(calls[0].init.body);
|
|
123
|
+
expect(body.metadata.arena).toBe("acme");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rejects missing clientId or content", async () => {
|
|
127
|
+
mockOk({});
|
|
128
|
+
await expect(engineStore("https://e", { content: "x" })).rejects.toThrow(
|
|
129
|
+
/clientId/
|
|
130
|
+
);
|
|
131
|
+
await expect(engineStore("https://e", { clientId: "a" })).rejects.toThrow(
|
|
132
|
+
/content/
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("engineSearch", () => {
|
|
138
|
+
it("builds canonical /search body and forwards arena/limit/min_score", async () => {
|
|
139
|
+
mockOk({ results: [] });
|
|
140
|
+
await engineSearch("https://e", {
|
|
141
|
+
clientId: "acme",
|
|
142
|
+
query: "hello",
|
|
143
|
+
limit: 5,
|
|
144
|
+
minScore: 0.5,
|
|
145
|
+
});
|
|
146
|
+
const body = JSON.parse(calls[0].init.body);
|
|
147
|
+
expect(calls[0].url).toBe("https://e/search");
|
|
148
|
+
expect(body).toEqual({
|
|
149
|
+
arena: "acme",
|
|
150
|
+
query: "hello",
|
|
151
|
+
limit: 5,
|
|
152
|
+
min_score: 0.5,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("includes metadata_filter only when non-empty", async () => {
|
|
157
|
+
mockOk({ results: [] });
|
|
158
|
+
await engineSearch("https://e", {
|
|
159
|
+
clientId: "acme",
|
|
160
|
+
query: "hi",
|
|
161
|
+
metadataFilter: { kind: "note" },
|
|
162
|
+
});
|
|
163
|
+
const body = JSON.parse(calls[0].init.body);
|
|
164
|
+
expect(body.metadata_filter).toEqual({ kind: "note" });
|
|
165
|
+
|
|
166
|
+
calls.length = 0;
|
|
167
|
+
mockOk({ results: [] });
|
|
168
|
+
await engineSearch("https://e", {
|
|
169
|
+
clientId: "acme",
|
|
170
|
+
query: "hi",
|
|
171
|
+
metadataFilter: {},
|
|
172
|
+
});
|
|
173
|
+
const body2 = JSON.parse(calls[0].init.body);
|
|
174
|
+
expect(body2.metadata_filter).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("uses defaults for limit + minScore", async () => {
|
|
178
|
+
mockOk({ results: [] });
|
|
179
|
+
await engineSearch("https://e", { clientId: "acme", query: "hi" });
|
|
180
|
+
const body = JSON.parse(calls[0].init.body);
|
|
181
|
+
expect(body.limit).toBe(10);
|
|
182
|
+
expect(body.min_score).toBe(0.3);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("engineForget", () => {
|
|
187
|
+
it("forwards id when provided", async () => {
|
|
188
|
+
mockOk({ deleted: 1 });
|
|
189
|
+
await engineForget("https://e", { clientId: "acme", id: "abc" });
|
|
190
|
+
const body = JSON.parse(calls[0].init.body);
|
|
191
|
+
expect(calls[0].url).toBe("https://e/forget");
|
|
192
|
+
expect(body).toEqual({ arena: "acme", id: "abc" });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("forwards metadata_contains when provided", async () => {
|
|
196
|
+
mockOk({ deleted: 5 });
|
|
197
|
+
await engineForget("https://e", {
|
|
198
|
+
clientId: "acme",
|
|
199
|
+
metadataContains: { source_repo: "monorepo" },
|
|
200
|
+
});
|
|
201
|
+
const body = JSON.parse(calls[0].init.body);
|
|
202
|
+
expect(body).toEqual({
|
|
203
|
+
arena: "acme",
|
|
204
|
+
metadata_contains: { source_repo: "monorepo" },
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("requires id or metadataContains", async () => {
|
|
209
|
+
await expect(
|
|
210
|
+
engineForget("https://e", { clientId: "acme" })
|
|
211
|
+
).rejects.toThrow(/id or metadataContains/);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4-layer projection over the memory engine.
|
|
3
|
+
*
|
|
4
|
+
* The engine's internal architecture has 7 storage layers (L0 BM25,
|
|
5
|
+
* L1 system files, L2 HybridRAG, L3 Neo4j KG, L4 sqlite-vec, L5 Milvus
|
|
6
|
+
* comms, L6 doc-store). The user-facing surface preferred by the SDK
|
|
7
|
+
* and the dashboard is a simpler 4-layer view: episodic / semantic /
|
|
8
|
+
* procedural / working.
|
|
9
|
+
*
|
|
10
|
+
* The engine itself doesn't model these — it stores everything as
|
|
11
|
+
* chunks tagged with `arena` + arbitrary `metadata`. The 4-layer view
|
|
12
|
+
* is synthesised here at the host boundary so callers see a familiar
|
|
13
|
+
* shape regardless of the storage architecture below.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import {
|
|
17
|
+
* buildStaticLayers,
|
|
18
|
+
* layerIdToType,
|
|
19
|
+
* STATIC_LAYER_TYPES,
|
|
20
|
+
* } from "@pentatonic-ai/ai-agent-sdk/memory/engine-layers";
|
|
21
|
+
*
|
|
22
|
+
* // Resolver for a `memoryLayers(clientId)` field:
|
|
23
|
+
* const layers = buildStaticLayers("acme");
|
|
24
|
+
* // → 4 entries with stable ids ml_acme_episodic, ml_acme_semantic, ...
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const STATIC_LAYER_TYPES = [
|
|
28
|
+
"episodic",
|
|
29
|
+
"semantic",
|
|
30
|
+
"procedural",
|
|
31
|
+
"working",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Synthesise the 4-layer surface for a tenant.
|
|
36
|
+
*
|
|
37
|
+
* Returned shape matches the legacy `memory_layers` row schema so
|
|
38
|
+
* existing GraphQL types and dashboard code work unchanged. Per-layer
|
|
39
|
+
* memory counts are null because the engine doesn't track them.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} clientId
|
|
42
|
+
* @returns {Array<{id: string, client_id: string, name: string, layer_type: string, capacity: null, decay_policy: null, is_active: true, created_at: string, memory_count: null}>}
|
|
43
|
+
*/
|
|
44
|
+
export function buildStaticLayers(clientId) {
|
|
45
|
+
const nowIso = new Date(0).toISOString(); // stable, install-time
|
|
46
|
+
return STATIC_LAYER_TYPES.map((type) => ({
|
|
47
|
+
id: `ml_${clientId}_${type}`,
|
|
48
|
+
client_id: clientId,
|
|
49
|
+
name: type,
|
|
50
|
+
layer_type: type,
|
|
51
|
+
capacity: null,
|
|
52
|
+
decay_policy: null,
|
|
53
|
+
is_active: true,
|
|
54
|
+
created_at: nowIso,
|
|
55
|
+
memory_count: null,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract the layer type from a layer id of the form
|
|
61
|
+
* `ml_<clientId>_<type>`. Falls back to "episodic" for inputs that
|
|
62
|
+
* don't match — keeps writes alive when the SDK is ahead of the host
|
|
63
|
+
* on layer naming.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} layerId
|
|
66
|
+
* @returns {"episodic" | "semantic" | "procedural" | "working"}
|
|
67
|
+
*/
|
|
68
|
+
export function layerIdToType(layerId) {
|
|
69
|
+
if (typeof layerId !== "string") return "episodic";
|
|
70
|
+
const last = layerId.split("_").pop();
|
|
71
|
+
return STATIC_LAYER_TYPES.includes(last) ? last : "episodic";
|
|
72
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Pentatonic memory engine (compat shim API).
|
|
3
|
+
*
|
|
4
|
+
* The engine speaks four endpoints:
|
|
5
|
+
*
|
|
6
|
+
* POST /store { content, metadata } → { id, content, layerId, engine }
|
|
7
|
+
* POST /store-batch { records[], arena? } → { ids, inserted, engine }
|
|
8
|
+
* POST /search { query, limit?, arena?, ... } → { results }
|
|
9
|
+
* POST /forget { id? } | { metadata_contains? } → { deleted }
|
|
10
|
+
*
|
|
11
|
+
* This module is the canonical wire-shape contract: any host (TES,
|
|
12
|
+
* Workers, Lambda) imports the same builders so the engine never sees
|
|
13
|
+
* inconsistent payloads. The host owns auth, audit, multi-tenant
|
|
14
|
+
* routing, and the surface format it exposes to its callers.
|
|
15
|
+
*
|
|
16
|
+
* No Node-only APIs — Workers / browser-fetch friendly.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* import { engineStore, engineSearch, engineForget }
|
|
20
|
+
* from "@pentatonic-ai/ai-agent-sdk/memory/engine";
|
|
21
|
+
*
|
|
22
|
+
* const ENGINE = process.env.MEMORY_ENGINE_URL;
|
|
23
|
+
*
|
|
24
|
+
* const stored = await engineStore(ENGINE, {
|
|
25
|
+
* clientId: "acme",
|
|
26
|
+
* content: "Alice owns project Atlas",
|
|
27
|
+
* metadata: { kind: "note" },
|
|
28
|
+
* layerType: "episodic",
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* const { results } = await engineSearch(ENGINE, {
|
|
32
|
+
* clientId: "acme",
|
|
33
|
+
* query: "Alice",
|
|
34
|
+
* limit: 6,
|
|
35
|
+
* });
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_ENGINE_URL = "https://memory-engine.thingeventsystem.ai";
|
|
39
|
+
export const DEFAULT_LIMIT = 10;
|
|
40
|
+
export const DEFAULT_MIN_SCORE = 0.3;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {object} EngineSearchHit
|
|
44
|
+
* @property {string} id
|
|
45
|
+
* @property {string} content
|
|
46
|
+
* @property {object} [metadata]
|
|
47
|
+
* @property {number} [similarity]
|
|
48
|
+
* @property {number} [score]
|
|
49
|
+
* @property {string} [layer_id]
|
|
50
|
+
* @property {string} [engine_layer]
|
|
51
|
+
* @property {string} [source]
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {object} EngineStoreResult
|
|
56
|
+
* @property {string} id
|
|
57
|
+
* @property {string} content
|
|
58
|
+
* @property {string} layerId
|
|
59
|
+
* @property {object} [engine] - per-layer index counts
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Low-level POST to the engine HTTP API. Mostly an internal helper —
|
|
64
|
+
* prefer the higher-level builders (`engineStore`, `engineSearch`,
|
|
65
|
+
* `engineForget`) which encode the canonical body shape.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} engineUrl - engine base URL (no trailing slash)
|
|
68
|
+
* @param {string} path - "/store" | "/search" | "/forget" | "/health" | "/store-batch"
|
|
69
|
+
* @param {object} body - JSON body, serialised verbatim
|
|
70
|
+
* @returns {Promise<object>} parsed JSON response
|
|
71
|
+
* @throws {Error} - "engine_<status>" with `.detail` set to response text
|
|
72
|
+
* on HTTP non-2xx, or "engine_network: <msg>" on
|
|
73
|
+
* transport failure.
|
|
74
|
+
*/
|
|
75
|
+
export async function fetchEngine(engineUrl, path, body) {
|
|
76
|
+
const base = engineUrl || DEFAULT_ENGINE_URL;
|
|
77
|
+
const url = `${base}${path}`;
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetch(url, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error(`engine_network: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
let detail = "";
|
|
90
|
+
try {
|
|
91
|
+
detail = await res.text();
|
|
92
|
+
} catch {
|
|
93
|
+
// ignore body-read failure; status alone is enough to fail
|
|
94
|
+
}
|
|
95
|
+
const e = new Error(`engine_${res.status}`);
|
|
96
|
+
e.detail = detail;
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
return res.json();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Store a single memory in the engine.
|
|
104
|
+
*
|
|
105
|
+
* Builds the canonical /store body: `arena = clientId` is set on
|
|
106
|
+
* metadata so the engine's multi-tenant scoping works. Caller-supplied
|
|
107
|
+
* metadata fields take precedence on conflict.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} engineUrl
|
|
110
|
+
* @param {object} opts
|
|
111
|
+
* @param {string} opts.clientId tenant id (becomes engine arena)
|
|
112
|
+
* @param {string} opts.content
|
|
113
|
+
* @param {object} [opts.metadata] extra metadata; merged into engine body
|
|
114
|
+
* @param {string} [opts.layerType] "episodic" | "semantic" | "procedural" | "working"
|
|
115
|
+
* @param {string} [opts.actorUserId] passes through as metadata.actor_user_id
|
|
116
|
+
* @returns {Promise<EngineStoreResult>}
|
|
117
|
+
*/
|
|
118
|
+
export async function engineStore(engineUrl, opts) {
|
|
119
|
+
const {
|
|
120
|
+
clientId,
|
|
121
|
+
content,
|
|
122
|
+
metadata = {},
|
|
123
|
+
layerType,
|
|
124
|
+
actorUserId,
|
|
125
|
+
} = opts || {};
|
|
126
|
+
if (!clientId) throw new Error("engineStore: clientId required");
|
|
127
|
+
if (typeof content !== "string") throw new Error("engineStore: content required");
|
|
128
|
+
const body = {
|
|
129
|
+
content,
|
|
130
|
+
metadata: {
|
|
131
|
+
...metadata,
|
|
132
|
+
arena: clientId,
|
|
133
|
+
...(layerType ? { layer_type: layerType } : {}),
|
|
134
|
+
...(actorUserId !== undefined ? { actor_user_id: actorUserId } : {}),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
return fetchEngine(engineUrl, "/store", body);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Search the engine, scoped to a tenant.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} engineUrl
|
|
144
|
+
* @param {object} opts
|
|
145
|
+
* @param {string} opts.clientId
|
|
146
|
+
* @param {string} opts.query
|
|
147
|
+
* @param {number} [opts.limit=10]
|
|
148
|
+
* @param {number} [opts.minScore=0.3]
|
|
149
|
+
* @param {object} [opts.metadataFilter] arbitrary equality filter on result metadata
|
|
150
|
+
* @returns {Promise<{results: EngineSearchHit[]}>}
|
|
151
|
+
*/
|
|
152
|
+
export async function engineSearch(engineUrl, opts) {
|
|
153
|
+
const {
|
|
154
|
+
clientId,
|
|
155
|
+
query,
|
|
156
|
+
limit = DEFAULT_LIMIT,
|
|
157
|
+
minScore = DEFAULT_MIN_SCORE,
|
|
158
|
+
metadataFilter,
|
|
159
|
+
} = opts || {};
|
|
160
|
+
if (!clientId) throw new Error("engineSearch: clientId required");
|
|
161
|
+
if (typeof query !== "string") throw new Error("engineSearch: query required");
|
|
162
|
+
const body = {
|
|
163
|
+
arena: clientId,
|
|
164
|
+
query,
|
|
165
|
+
limit,
|
|
166
|
+
min_score: minScore,
|
|
167
|
+
...(metadataFilter && Object.keys(metadataFilter).length
|
|
168
|
+
? { metadata_filter: metadataFilter }
|
|
169
|
+
: {}),
|
|
170
|
+
};
|
|
171
|
+
return fetchEngine(engineUrl, "/search", body);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Forget memories by id or by metadata equality.
|
|
176
|
+
*
|
|
177
|
+
* Caller must supply exactly one of `id` or `metadataContains`.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} engineUrl
|
|
180
|
+
* @param {object} opts
|
|
181
|
+
* @param {string} opts.clientId
|
|
182
|
+
* @param {string} [opts.id] forget a single record by engine id
|
|
183
|
+
* @param {object} [opts.metadataContains] forget all records matching every key=value pair
|
|
184
|
+
* @returns {Promise<{deleted: number}>}
|
|
185
|
+
*/
|
|
186
|
+
export async function engineForget(engineUrl, opts) {
|
|
187
|
+
const { clientId, id, metadataContains } = opts || {};
|
|
188
|
+
if (!clientId) throw new Error("engineForget: clientId required");
|
|
189
|
+
if (!id && !metadataContains) {
|
|
190
|
+
throw new Error("engineForget: provide id or metadataContains");
|
|
191
|
+
}
|
|
192
|
+
const body = {
|
|
193
|
+
arena: clientId,
|
|
194
|
+
...(id ? { id } : {}),
|
|
195
|
+
...(metadataContains ? { metadata_contains: metadataContains } : {}),
|
|
196
|
+
};
|
|
197
|
+
return fetchEngine(engineUrl, "/forget", body);
|
|
198
|
+
}
|
|
@@ -420,12 +420,19 @@ async def health():
|
|
|
420
420
|
if failures:
|
|
421
421
|
out["status"] = "degraded" if failures < 3 else "down"
|
|
422
422
|
|
|
423
|
-
# Memory count: query L6 doc-store as authoritative.
|
|
423
|
+
# Memory count: query L6 doc-store as authoritative. L6 /stats
|
|
424
|
+
# returns vector_chunks (Milvus) and fts_chunks (sqlite content
|
|
425
|
+
# table). Under healthy operation they're equal — take the max so
|
|
426
|
+
# the count is honest if one side is mid-rebuild.
|
|
424
427
|
try:
|
|
425
428
|
r = await _client().get(f"{L6_DOC_URL}/stats", timeout=3.0)
|
|
426
429
|
if r.status_code == 200:
|
|
427
430
|
stats = r.json()
|
|
428
|
-
out["memories"] =
|
|
431
|
+
out["memories"] = max(
|
|
432
|
+
int(stats.get("vector_chunks") or 0),
|
|
433
|
+
int(stats.get("fts_chunks") or 0),
|
|
434
|
+
int(stats.get("total_chunks") or 0),
|
|
435
|
+
)
|
|
429
436
|
except Exception:
|
|
430
437
|
out["memories"] = None
|
|
431
438
|
return out
|
|
@@ -29,20 +29,26 @@ services:
|
|
|
29
29
|
retries: 20
|
|
30
30
|
start_period: 5s
|
|
31
31
|
|
|
32
|
+
# Pin the embedding dim explicitly across layers, independent of any
|
|
33
|
+
# developer-local .env (which may set EMBED_DIM=768 for Ollama-based
|
|
34
|
+
# local dev). The stub returns 4096; layers must agree.
|
|
32
35
|
l4:
|
|
33
36
|
environment:
|
|
34
37
|
L4_NV_EMBED_URL: http://embed-stub:8041/v1/embeddings
|
|
35
38
|
L4_EMBED_API_KEY: ""
|
|
39
|
+
L4_EMBED_DIM: "4096"
|
|
36
40
|
|
|
37
41
|
l5:
|
|
38
42
|
environment:
|
|
39
43
|
L5_NV_EMBED_URL: http://embed-stub:8041/v1/embeddings
|
|
40
44
|
L5_EMBED_API_KEY: ""
|
|
45
|
+
L5_EMBED_DIM: "4096"
|
|
41
46
|
|
|
42
47
|
l6:
|
|
43
48
|
environment:
|
|
44
49
|
L6_NV_EMBED_URL: http://embed-stub:8041/v1/embeddings
|
|
45
50
|
L6_EMBED_API_KEY: ""
|
|
51
|
+
L6_EMBED_DIM: "4096"
|
|
46
52
|
|
|
47
53
|
l2:
|
|
48
54
|
environment:
|
|
@@ -911,16 +911,34 @@ def serve(port: int = DEFAULT_PORT):
|
|
|
911
911
|
milvus.insert(collection_name=COLLECTION_NAME, data=rows)
|
|
912
912
|
insert_ms = (_time.time() - t1) * 1000.0
|
|
913
913
|
|
|
914
|
-
# Single FTS write
|
|
914
|
+
# Single FTS write — into the `chunks` content table; the
|
|
915
|
+
# AFTER INSERT trigger replicates rows into the chunks_fts
|
|
916
|
+
# virtual table so BM25 search (which JOINs chunks ON rowid)
|
|
917
|
+
# actually finds them. Earlier versions wrote directly to
|
|
918
|
+
# chunks_fts, leaving `chunks` empty — which made BM25 return
|
|
919
|
+
# zero hits AND broke the /stats fts_chunks counter.
|
|
915
920
|
try:
|
|
916
921
|
fts_conn = get_fts_db()
|
|
917
922
|
for r, txt in zip(records, texts):
|
|
918
923
|
rid = r.get("id") or _hashlib.sha1(txt.encode("utf-8")).hexdigest()[:32]
|
|
924
|
+
chunk_id = f"l6:{rid}:0"[:63]
|
|
919
925
|
fts_conn.execute(
|
|
920
|
-
"INSERT
|
|
921
|
-
"
|
|
922
|
-
|
|
923
|
-
|
|
926
|
+
"INSERT OR REPLACE INTO chunks "
|
|
927
|
+
"(id, text, source_file, arena, doc_type, heading, "
|
|
928
|
+
" chunk_index, content_hash, entities_json, indexed_at) "
|
|
929
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
930
|
+
(
|
|
931
|
+
chunk_id,
|
|
932
|
+
txt,
|
|
933
|
+
(r.get("source_file") or f"{rid}.md"),
|
|
934
|
+
arena,
|
|
935
|
+
(r.get("doc_type") or "general"),
|
|
936
|
+
(r.get("heading") or ""),
|
|
937
|
+
0,
|
|
938
|
+
_hashlib.sha1(txt.encode("utf-8")).hexdigest()[:20],
|
|
939
|
+
"[]",
|
|
940
|
+
now,
|
|
941
|
+
),
|
|
924
942
|
)
|
|
925
943
|
fts_conn.commit()
|
|
926
944
|
fts_conn.close()
|