@pentatonic-ai/ai-agent-sdk 0.7.6 → 0.7.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
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
+ }
@@ -438,12 +438,25 @@ async def health():
438
438
  return out
439
439
 
440
440
 
441
+ def _arena_scoped_rid(arena: str, content: str) -> str:
442
+ """Stable, arena-scoped record id.
443
+
444
+ Same content in different arenas hashes to *different* ids — so two
445
+ tenants storing the byte-identical chunk get distinct primary keys
446
+ in the underlying stores (L4 sqlite-vec, L5 Milvus, L6 Milvus).
447
+ Without this, arena=tenant-b's write would overwrite arena=tenant-a's
448
+ row of the same content, silently breaking multi-tenant isolation
449
+ for any duplicated phrase.
450
+ """
451
+ return hashlib.sha1(f"{arena}:{content}".encode()).hexdigest()[:32]
452
+
453
+
441
454
  @app.post("/store")
442
455
  async def store(req: StoreRequest):
443
456
  """Single-record ingest. Same wire format as pentatonic-memory v0.5."""
444
- rid = (req.metadata or {}).get("id") or hashlib.sha1(req.content.encode()).hexdigest()[:32]
445
- record = {"id": rid, "content": req.content, "metadata": req.metadata or {}}
446
457
  arena = (req.metadata or {}).get("arena", "general")
458
+ rid = (req.metadata or {}).get("id") or _arena_scoped_rid(arena, req.content)
459
+ record = {"id": rid, "content": req.content, "metadata": req.metadata or {}}
447
460
 
448
461
  # Stash the full metadata under every key shape any layer could echo back.
449
462
  # L5/L6 use derivatives of rid; L2-internal returns paths shaped like
@@ -483,16 +496,16 @@ async def store_batch(req: StoreBatchRequest):
483
496
  return {"inserted": 0, "ids": []}
484
497
 
485
498
  # Normalise each record to {id, content, metadata}.
499
+ arena = req.arena or "general"
486
500
  normalised = []
487
501
  for r in req.records:
488
502
  content = r.get("content") or r.get("text") or ""
489
503
  if not content:
490
504
  continue
491
- rid = r.get("id") or hashlib.sha1(content.encode()).hexdigest()[:32]
505
+ rid = r.get("id") or _arena_scoped_rid(arena, content)
492
506
  normalised.append({"id": rid, "content": content, "metadata": r.get("metadata") or {}})
493
507
 
494
508
  # Stash metadata for every record so /search can re-attach it.
495
- arena = req.arena or "general"
496
509
  for r in normalised:
497
510
  _stash_all_keys(r["id"], r.get("metadata") or {}, arena)
498
511
 
@@ -970,6 +970,77 @@ def serve(port: int = DEFAULT_PORT):
970
970
  milvus.load_collection(COLLECTION_NAME)
971
971
  return {"status": "rebuilt"}
972
972
 
973
+ @api.post("/repair-fts")
974
+ def api_repair_fts():
975
+ """Backfill the SQLite `chunks` content table from Milvus.
976
+
977
+ Pre-v0.7.6 the /index-batch path wrote straight to the FTS5
978
+ virtual table and never populated `chunks`. The result: BM25
979
+ search (which JOINs chunks ON rowid) returned zero hits even
980
+ though Milvus had the data, and /stats fts_chunks reported 0.
981
+
982
+ v0.7.6 fixed new writes; this endpoint cleans up old rows by
983
+ walking Milvus and INSERT-OR-REPLACE'ing every row into chunks.
984
+ The trigger on chunks then mirrors them into chunks_fts. Idempotent.
985
+ Runs in-process so the L6 service's already-open Milvus handle
986
+ is reused — no file-lock conflict (Milvus Lite locks the .db).
987
+ """
988
+ milvus = get_milvus()
989
+ # Milvus Lite caps query() at 16384 per call; page through.
990
+ page_size = 16384
991
+ offset = 0
992
+ rows: list = []
993
+ while True:
994
+ page = milvus.query(
995
+ COLLECTION_NAME,
996
+ filter="id != ''",
997
+ output_fields=[
998
+ "id", "text", "source_file", "arena", "doc_type",
999
+ "heading", "chunk_index", "content_hash",
1000
+ "entities_json", "indexed_at",
1001
+ ],
1002
+ limit=page_size,
1003
+ offset=offset,
1004
+ )
1005
+ if not page:
1006
+ break
1007
+ rows.extend(page)
1008
+ if len(page) < page_size:
1009
+ break
1010
+ offset += page_size
1011
+ fts_conn = get_fts_db()
1012
+ repaired = 0
1013
+ for r in rows:
1014
+ try:
1015
+ fts_conn.execute(
1016
+ "INSERT OR REPLACE INTO chunks "
1017
+ "(id, text, source_file, arena, doc_type, heading, "
1018
+ " chunk_index, content_hash, entities_json, indexed_at) "
1019
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1020
+ (
1021
+ r.get("id"),
1022
+ r.get("text", "") or "",
1023
+ r.get("source_file", "") or "",
1024
+ r.get("arena", "") or "general",
1025
+ r.get("doc_type", "") or "general",
1026
+ r.get("heading", "") or "",
1027
+ int(r.get("chunk_index", 0) or 0),
1028
+ r.get("content_hash", "") or "",
1029
+ r.get("entities_json", "") or "[]",
1030
+ r.get("indexed_at", "") or "",
1031
+ ),
1032
+ )
1033
+ repaired += 1
1034
+ except Exception as exc:
1035
+ log.warning("repair-fts: skipping row %s: %s", r.get("id"), exc)
1036
+ fts_conn.commit()
1037
+ fts_conn.close()
1038
+ return {
1039
+ "status": "ok",
1040
+ "milvus_rows": len(rows),
1041
+ "repaired": repaired,
1042
+ }
1043
+
973
1044
  log.info(f"L6 Document Store — http://127.0.0.1:{port}")
974
1045
  uvicorn.run(api, host=os.environ.get("HOST","127.0.0.1"), port=port, log_level="info")
975
1046
 
@@ -125,6 +125,37 @@ print("yes" if ok and data else "no")')
125
125
  [ "$all_match" = "yes" ] && ok "metadata_filter scopes to probe + arena" \
126
126
  || fail "metadata_filter let other rows through"
127
127
 
128
+ # ---------------------------------------------------------------------------
129
+ # Same content across two arenas — proves the arena-aware id derivation.
130
+ # Pre-v0.7.8, identical content collapsed to one row in L4/L5/L6 because
131
+ # the id was sha1(content); the second tenant's write overwrote the first.
132
+ # ---------------------------------------------------------------------------
133
+
134
+ echo ""
135
+ echo "=== same-content-across-arenas ==="
136
+ post '{"content":"shared phrase about Eclipse","metadata":{"arena":"e2e-tenant-x","probe":"e2e-arena"}}' >/dev/null
137
+ post '{"content":"shared phrase about Eclipse","metadata":{"arena":"e2e-tenant-y","probe":"e2e-arena"}}' >/dev/null
138
+ sleep 3
139
+
140
+ SX=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
141
+ -d '{"query":"Eclipse","limit":10,"arena":"e2e-tenant-x"}')
142
+ SY=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
143
+ -d '{"query":"Eclipse","limit":10,"arena":"e2e-tenant-y"}')
144
+
145
+ x_has_phrase=$(echo "$SX" | python3 -c '
146
+ import json,sys
147
+ data=json.load(sys.stdin).get("results",[])
148
+ print("yes" if any("Eclipse" in r.get("content","") for r in data) else "no")')
149
+ y_has_phrase=$(echo "$SY" | python3 -c '
150
+ import json,sys
151
+ data=json.load(sys.stdin).get("results",[])
152
+ print("yes" if any("Eclipse" in r.get("content","") for r in data) else "no")')
153
+
154
+ [ "$x_has_phrase" = "yes" ] && ok "tenant-x: shared phrase preserved" \
155
+ || fail "tenant-x lost the shared phrase (id collision?)"
156
+ [ "$y_has_phrase" = "yes" ] && ok "tenant-y: shared phrase preserved" \
157
+ || fail "tenant-y lost the shared phrase (id collision?)"
158
+
128
159
  # ---------------------------------------------------------------------------
129
160
  # /forget — by metadata_contains. Cleans up so reruns are idempotent.
130
161
  # ---------------------------------------------------------------------------