@pentatonic-ai/ai-agent-sdk 0.5.7 → 0.5.9
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/dist/index.cjs +244 -8
- package/dist/index.js +244 -8
- package/package.json +2 -2
- package/packages/doctor/__tests__/checks.test.js +357 -0
- package/packages/doctor/src/checks/claude-code.js +100 -0
- package/packages/doctor/src/checks/data-flow.js +252 -0
- package/packages/doctor/src/index.js +2 -0
- package/packages/doctor/src/runner.js +7 -3
- package/packages/memory/src/__tests__/api-contract.test.js +151 -0
- package/packages/memory/src/hosted.js +7 -0
- package/packages/memory/src/ingest.js +40 -1
- package/packages/memory/src/inject.js +83 -0
- package/src/client.js +20 -2
- package/src/wrapper.js +129 -6
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted TES data-flow checks.
|
|
3
|
+
*
|
|
4
|
+
* The existing hosted-tes checks prove the TES server is up and the API
|
|
5
|
+
* key is accepted. They don't prove data is actually flowing end-to-end —
|
|
6
|
+
* you can have a green doctor pass while the Claude Code hook is silently
|
|
7
|
+
* dropping events, or while vector retrieval is returning nothing at the
|
|
8
|
+
* configured minScore.
|
|
9
|
+
*
|
|
10
|
+
* These checks close that gap with three real-data probes against the
|
|
11
|
+
* same GraphQL endpoint the SDK already uses at runtime:
|
|
12
|
+
*
|
|
13
|
+
* - "TES event stream has data" — events table has rows at all
|
|
14
|
+
* - "MEMORY_CREATED events present" — memory events exist for this client
|
|
15
|
+
* - "semantic search returns hits" — a broad probe query retrieves > 0
|
|
16
|
+
*
|
|
17
|
+
* All three are WARNINGs by default: a green liveness check + a "0 events"
|
|
18
|
+
* warning is more informative than pretending liveness implies correctness,
|
|
19
|
+
* but an empty stream on a fresh install is legitimate and shouldn't fail
|
|
20
|
+
* the overall doctor pass.
|
|
21
|
+
*
|
|
22
|
+
* GraphQL shapes match TES's deployed schema (verified against
|
|
23
|
+
* thing-event-system/functions/api/graphql/domains/event/schema.js and
|
|
24
|
+
* thing-event-system/modules/deep-memory/graphql/memory/schema.js):
|
|
25
|
+
*
|
|
26
|
+
* events(filter: EventFilterInput, limit: Int, offset: Int): EventPage!
|
|
27
|
+
* EventFilterInput { eventType: StringFilterInput, clientId: StringFilterInput, ... }
|
|
28
|
+
* EventPage { totalCount: Int!, ... }
|
|
29
|
+
*
|
|
30
|
+
* semanticSearchMemories(
|
|
31
|
+
* clientId: String!,
|
|
32
|
+
* query: String!,
|
|
33
|
+
* userId: String,
|
|
34
|
+
* limit: Int,
|
|
35
|
+
* minScore: Float
|
|
36
|
+
* ): [SemanticMemoryResult!]!
|
|
37
|
+
* SemanticMemoryResult { id: String!, similarity: Float!, ... }
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { SEVERITY } from "../index.js";
|
|
41
|
+
|
|
42
|
+
async function fetchWithTimeout(url, opts = {}, timeoutMs = 10_000) {
|
|
43
|
+
return await fetch(url, {
|
|
44
|
+
...opts,
|
|
45
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Auth header: TES accepts `Authorization: Bearer tes_...` for end-user
|
|
51
|
+
* keys and `x-service-key: <key>` for internal/service keys. Mirrors the
|
|
52
|
+
* branching in hooks/scripts/shared.js so doctor authenticates the same
|
|
53
|
+
* way the SDK runtime does.
|
|
54
|
+
*/
|
|
55
|
+
function authHeaders(apiKey, clientId) {
|
|
56
|
+
const headers = {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"x-client-id": clientId,
|
|
59
|
+
};
|
|
60
|
+
if (apiKey?.startsWith("tes_")) {
|
|
61
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
62
|
+
} else if (apiKey) {
|
|
63
|
+
headers["x-service-key"] = apiKey;
|
|
64
|
+
}
|
|
65
|
+
return headers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function graphql(endpoint, apiKey, clientId, query, variables) {
|
|
69
|
+
const res = await fetchWithTimeout(
|
|
70
|
+
`${endpoint.replace(/\/$/, "")}/api/graphql`,
|
|
71
|
+
{
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: authHeaders(apiKey, clientId),
|
|
74
|
+
body: JSON.stringify({ query, variables }),
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const text = await res.text().catch(() => "");
|
|
79
|
+
throw new Error(`HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
|
|
80
|
+
}
|
|
81
|
+
const body = await res.json();
|
|
82
|
+
if (body.errors?.length) {
|
|
83
|
+
throw new Error(body.errors[0].message || "graphql error");
|
|
84
|
+
}
|
|
85
|
+
return body.data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requireHostedEnv() {
|
|
89
|
+
const endpoint = process.env.TES_ENDPOINT;
|
|
90
|
+
const apiKey = process.env.TES_API_KEY;
|
|
91
|
+
const clientId = process.env.TES_CLIENT_ID;
|
|
92
|
+
if (!endpoint || !apiKey || !clientId) {
|
|
93
|
+
return {
|
|
94
|
+
missing: true,
|
|
95
|
+
reason: "TES_ENDPOINT / TES_API_KEY / TES_CLIENT_ID required",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { endpoint, apiKey, clientId };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function checkEventStreamHasData() {
|
|
102
|
+
return {
|
|
103
|
+
name: "TES event stream has data",
|
|
104
|
+
severity: SEVERITY.WARNING,
|
|
105
|
+
run: async () => {
|
|
106
|
+
const env = requireHostedEnv();
|
|
107
|
+
if (env.missing) return { ok: false, msg: env.reason };
|
|
108
|
+
try {
|
|
109
|
+
// `limit: 1` keeps the payload tiny — we only care about the total.
|
|
110
|
+
const data = await graphql(
|
|
111
|
+
env.endpoint,
|
|
112
|
+
env.apiKey,
|
|
113
|
+
env.clientId,
|
|
114
|
+
`query DoctorEventCount { events(limit: 1) { totalCount } }`
|
|
115
|
+
);
|
|
116
|
+
const total = data?.events?.totalCount ?? 0;
|
|
117
|
+
if (total > 0) {
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
msg: `${total} event(s) in stream`,
|
|
121
|
+
detail: { totalCount: total },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
msg: "0 events yet — send one prompt to your agent and re-run",
|
|
127
|
+
detail: { totalCount: 0 },
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return { ok: false, msg: err.message };
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function checkMemoryCreatedForClient() {
|
|
137
|
+
return {
|
|
138
|
+
name: "MEMORY_CREATED events for client",
|
|
139
|
+
severity: SEVERITY.WARNING,
|
|
140
|
+
run: async () => {
|
|
141
|
+
const env = requireHostedEnv();
|
|
142
|
+
if (env.missing) return { ok: false, msg: env.reason };
|
|
143
|
+
try {
|
|
144
|
+
const data = await graphql(
|
|
145
|
+
env.endpoint,
|
|
146
|
+
env.apiKey,
|
|
147
|
+
env.clientId,
|
|
148
|
+
`query DoctorMemCount($eventType: String!, $client: String!) {
|
|
149
|
+
events(
|
|
150
|
+
limit: 1,
|
|
151
|
+
filter: {
|
|
152
|
+
eventType: { eq: $eventType }
|
|
153
|
+
clientId: { eq: $client }
|
|
154
|
+
}
|
|
155
|
+
) {
|
|
156
|
+
totalCount
|
|
157
|
+
}
|
|
158
|
+
}`,
|
|
159
|
+
{ eventType: "MEMORY_CREATED", client: env.clientId }
|
|
160
|
+
);
|
|
161
|
+
const total = data?.events?.totalCount ?? 0;
|
|
162
|
+
if (total > 0) {
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
msg: `${total} MEMORY_CREATED event(s) for ${env.clientId}`,
|
|
166
|
+
detail: { totalCount: total, clientId: env.clientId },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
msg: `no MEMORY_CREATED events for ${env.clientId} yet — hook may not be writing memories`,
|
|
172
|
+
detail: { totalCount: 0, clientId: env.clientId },
|
|
173
|
+
};
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { ok: false, msg: err.message };
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Match TES's "Cannot query field 'X'" error wording precisely so a
|
|
182
|
+
// schema-arg mismatch doesn't masquerade as "deployment doesn't expose
|
|
183
|
+
// the field" — that would silently hide real errors.
|
|
184
|
+
const FIELD_NOT_FOUND_RE =
|
|
185
|
+
/cannot query field "?semanticSearchMemories"?/i;
|
|
186
|
+
|
|
187
|
+
function checkSemanticSearchReturnsHits() {
|
|
188
|
+
return {
|
|
189
|
+
name: "semanticSearchMemories returns hits",
|
|
190
|
+
severity: SEVERITY.WARNING,
|
|
191
|
+
run: async () => {
|
|
192
|
+
const env = requireHostedEnv();
|
|
193
|
+
if (env.missing) return { ok: false, msg: env.reason };
|
|
194
|
+
try {
|
|
195
|
+
// A broad probe query. Low minScore (0.1) because the point of this
|
|
196
|
+
// check is "does retrieval work at all", not "does retrieval rank
|
|
197
|
+
// well". A follow-up tuning warning can be a separate check later.
|
|
198
|
+
const query = process.env.PENTATONIC_DOCTOR_PROBE_QUERY || "heartbeat";
|
|
199
|
+
const minScore = 0.1;
|
|
200
|
+
const data = await graphql(
|
|
201
|
+
env.endpoint,
|
|
202
|
+
env.apiKey,
|
|
203
|
+
env.clientId,
|
|
204
|
+
`query DoctorSearch($clientId: String!, $q: String!, $minScore: Float!) {
|
|
205
|
+
semanticSearchMemories(
|
|
206
|
+
clientId: $clientId,
|
|
207
|
+
query: $q,
|
|
208
|
+
minScore: $minScore,
|
|
209
|
+
limit: 5
|
|
210
|
+
) {
|
|
211
|
+
id
|
|
212
|
+
similarity
|
|
213
|
+
}
|
|
214
|
+
}`,
|
|
215
|
+
{ clientId: env.clientId, q: query, minScore }
|
|
216
|
+
);
|
|
217
|
+
const hits = data?.semanticSearchMemories ?? [];
|
|
218
|
+
if (hits.length > 0) {
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
msg: `${hits.length} hit(s) for "${query}" at minScore=${minScore}`,
|
|
222
|
+
detail: { query, minScore, hits: hits.length },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
msg: `0 hits for "${query}" at minScore=${minScore} — try lowering minScore or PENTATONIC_DOCTOR_PROBE_QUERY`,
|
|
228
|
+
detail: { query, minScore, hits: 0 },
|
|
229
|
+
};
|
|
230
|
+
} catch (err) {
|
|
231
|
+
// Only treat the precise "Cannot query field" error as
|
|
232
|
+
// "deployment doesn't expose this" — schema-arg mismatches and
|
|
233
|
+
// other graphql errors should surface, not be silently skipped.
|
|
234
|
+
if (FIELD_NOT_FOUND_RE.test(err.message)) {
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
msg: "semanticSearchMemories not exposed by this deployment (skipped)",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { ok: false, msg: err.message };
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function dataFlowChecks() {
|
|
247
|
+
return [
|
|
248
|
+
checkEventStreamHasData(),
|
|
249
|
+
checkMemoryCreatedForClient(),
|
|
250
|
+
checkSemanticSearchReturnsHits(),
|
|
251
|
+
];
|
|
252
|
+
}
|
|
@@ -24,7 +24,9 @@ export { renderHuman, renderJson } from "./output.js";
|
|
|
24
24
|
export { universalChecks } from "./checks/universal.js";
|
|
25
25
|
export { localMemoryChecks } from "./checks/local-memory.js";
|
|
26
26
|
export { hostedTesChecks } from "./checks/hosted-tes.js";
|
|
27
|
+
export { dataFlowChecks } from "./checks/data-flow.js";
|
|
27
28
|
export { platformChecks } from "./checks/platform.js";
|
|
29
|
+
export { claudeCodeChecks } from "./checks/claude-code.js";
|
|
28
30
|
|
|
29
31
|
export const SEVERITY = Object.freeze({
|
|
30
32
|
CRITICAL: "critical",
|
|
@@ -21,7 +21,9 @@ import { detectPaths, PATHS } from "./detect.js";
|
|
|
21
21
|
import { universalChecks } from "./checks/universal.js";
|
|
22
22
|
import { localMemoryChecks } from "./checks/local-memory.js";
|
|
23
23
|
import { hostedTesChecks } from "./checks/hosted-tes.js";
|
|
24
|
+
import { dataFlowChecks } from "./checks/data-flow.js";
|
|
24
25
|
import { platformChecks } from "./checks/platform.js";
|
|
26
|
+
import { claudeCodeChecks } from "./checks/claude-code.js";
|
|
25
27
|
import { loadPlugins } from "./plugins.js";
|
|
26
28
|
import { SEVERITY } from "./index.js";
|
|
27
29
|
|
|
@@ -32,7 +34,8 @@ function pathChecks(path) {
|
|
|
32
34
|
case PATHS.LOCAL:
|
|
33
35
|
return localMemoryChecks();
|
|
34
36
|
case PATHS.HOSTED:
|
|
35
|
-
|
|
37
|
+
// Liveness (hostedTesChecks) + end-to-end data-flow probes.
|
|
38
|
+
return [...hostedTesChecks(), ...dataFlowChecks()];
|
|
36
39
|
case PATHS.PLATFORM:
|
|
37
40
|
return platformChecks();
|
|
38
41
|
default:
|
|
@@ -91,8 +94,9 @@ export async function runDoctor(opts = {}) {
|
|
|
91
94
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
92
95
|
const paths = detectPaths(opts);
|
|
93
96
|
|
|
94
|
-
// Universal checks always run.
|
|
95
|
-
|
|
97
|
+
// Universal checks always run. claudeCodeChecks is also universal —
|
|
98
|
+
// the plugin may be present regardless of which install path is in use.
|
|
99
|
+
const checks = [...universalChecks(), ...claudeCodeChecks()];
|
|
96
100
|
for (const p of paths) {
|
|
97
101
|
checks.push(...pathChecks(p));
|
|
98
102
|
}
|
|
@@ -581,3 +581,154 @@ describe("ingest options contract", () => {
|
|
|
581
581
|
expect(registered.length).toBe(0);
|
|
582
582
|
});
|
|
583
583
|
});
|
|
584
|
+
|
|
585
|
+
// --- Ingest dedup ---
|
|
586
|
+
|
|
587
|
+
describe("ingest dedup option", () => {
|
|
588
|
+
function makeMockDb(state = {}) {
|
|
589
|
+
const calls = [];
|
|
590
|
+
const existing = state.existing || []; // [{ id, client_id, content }, ...]
|
|
591
|
+
const inserted = [];
|
|
592
|
+
const db = async (sql, params) => {
|
|
593
|
+
calls.push({ sql, params });
|
|
594
|
+
if (sql.includes("SELECT id FROM memory_layers")) {
|
|
595
|
+
return { rows: [{ id: "layer-1" }] };
|
|
596
|
+
}
|
|
597
|
+
// Dedup pre-check (raw + LIKE legacy form)
|
|
598
|
+
if (sql.includes("SELECT id FROM memory_nodes")) {
|
|
599
|
+
const [clientId, content] = params;
|
|
600
|
+
const match = existing.find(
|
|
601
|
+
(r) =>
|
|
602
|
+
r.client_id === clientId &&
|
|
603
|
+
(r.content === content ||
|
|
604
|
+
r.content.endsWith(`] ${content}`)) // legacy timestamp-prefixed
|
|
605
|
+
);
|
|
606
|
+
return { rows: match ? [{ id: match.id }] : [] };
|
|
607
|
+
}
|
|
608
|
+
// Insert path
|
|
609
|
+
if (sql.startsWith("INSERT INTO memory_nodes")) {
|
|
610
|
+
inserted.push({
|
|
611
|
+
id: params[0],
|
|
612
|
+
client_id: params[1],
|
|
613
|
+
content: params[3],
|
|
614
|
+
});
|
|
615
|
+
return { rows: [] };
|
|
616
|
+
}
|
|
617
|
+
return { rows: [] };
|
|
618
|
+
};
|
|
619
|
+
return { db, calls, inserted };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const mockAi = { embed: async () => null };
|
|
623
|
+
const mockLlm = { chat: async () => "[]" };
|
|
624
|
+
|
|
625
|
+
it("inserts a fresh row when no duplicate exists", async () => {
|
|
626
|
+
const { db, inserted } = makeMockDb({ existing: [] });
|
|
627
|
+
|
|
628
|
+
const out = await ingest(db, mockAi, mockLlm, "fresh content", {
|
|
629
|
+
clientId: "c",
|
|
630
|
+
dedup: true,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
expect(out.deduped).toBeUndefined();
|
|
634
|
+
expect(out.id.startsWith("mem_")).toBe(true);
|
|
635
|
+
expect(inserted).toHaveLength(1);
|
|
636
|
+
expect(inserted[0].content).toBe("fresh content");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("returns the existing row's id when raw content matches", async () => {
|
|
640
|
+
const { db, inserted } = makeMockDb({
|
|
641
|
+
existing: [
|
|
642
|
+
{ id: "mem_existing", client_id: "c", content: "duplicate content" },
|
|
643
|
+
],
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const out = await ingest(db, mockAi, mockLlm, "duplicate content", {
|
|
647
|
+
clientId: "c",
|
|
648
|
+
dedup: true,
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
expect(out.deduped).toBe(true);
|
|
652
|
+
expect(out.id).toBe("mem_existing");
|
|
653
|
+
expect(out.content).toBe("duplicate content");
|
|
654
|
+
expect(inserted).toHaveLength(0); // no insert happened
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("matches legacy timestamp-prefixed rows (`[<iso>] <content>`)", async () => {
|
|
658
|
+
const { db, inserted } = makeMockDb({
|
|
659
|
+
existing: [
|
|
660
|
+
{
|
|
661
|
+
id: "mem_legacy",
|
|
662
|
+
client_id: "c",
|
|
663
|
+
content: "[2026-04-26T10:00:00Z] duplicate content",
|
|
664
|
+
},
|
|
665
|
+
],
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const out = await ingest(db, mockAi, mockLlm, "duplicate content", {
|
|
669
|
+
clientId: "c",
|
|
670
|
+
dedup: true,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
expect(out.deduped).toBe(true);
|
|
674
|
+
expect(out.id).toBe("mem_legacy");
|
|
675
|
+
expect(inserted).toHaveLength(0);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("dedup off (default) still inserts on duplicate content", async () => {
|
|
679
|
+
const { db, inserted } = makeMockDb({
|
|
680
|
+
existing: [
|
|
681
|
+
{ id: "mem_existing", client_id: "c", content: "duplicate content" },
|
|
682
|
+
],
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
const out = await ingest(db, mockAi, mockLlm, "duplicate content", {
|
|
686
|
+
clientId: "c",
|
|
687
|
+
// dedup omitted — defaults to false
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
expect(out.deduped).toBeUndefined();
|
|
691
|
+
expect(inserted).toHaveLength(1);
|
|
692
|
+
expect(inserted[0].id).not.toBe("mem_existing");
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("scopes dedup to the given clientId (cross-tenant collisions don't dedup)", async () => {
|
|
696
|
+
const { db, inserted } = makeMockDb({
|
|
697
|
+
existing: [
|
|
698
|
+
{ id: "mem_other", client_id: "other", content: "duplicate content" },
|
|
699
|
+
],
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const out = await ingest(db, mockAi, mockLlm, "duplicate content", {
|
|
703
|
+
clientId: "c", // different tenant
|
|
704
|
+
dedup: true,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
expect(out.deduped).toBeUndefined();
|
|
708
|
+
expect(inserted).toHaveLength(1);
|
|
709
|
+
expect(inserted[0].client_id).toBe("c");
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("dedup check failure falls through to insert (best-effort semantics)", async () => {
|
|
713
|
+
let dupCheckSql = null;
|
|
714
|
+
const flakyDb = async (sql, params) => {
|
|
715
|
+
if (sql.includes("SELECT id FROM memory_layers")) {
|
|
716
|
+
return { rows: [{ id: "layer-1" }] };
|
|
717
|
+
}
|
|
718
|
+
if (sql.includes("SELECT id FROM memory_nodes")) {
|
|
719
|
+
dupCheckSql = sql;
|
|
720
|
+
throw new Error("DB unreachable");
|
|
721
|
+
}
|
|
722
|
+
return { rows: [] };
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const out = await ingest(flakyDb, mockAi, mockLlm, "content", {
|
|
726
|
+
clientId: "c",
|
|
727
|
+
dedup: true,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
expect(dupCheckSql).toContain("memory_nodes");
|
|
731
|
+
expect(out.deduped).toBeUndefined();
|
|
732
|
+
expect(out.id.startsWith("mem_")).toBe(true);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
@@ -370,3 +370,10 @@ function shortenReason(msg) {
|
|
|
370
370
|
.replace(/[^a-z0-9]+/g, "_")
|
|
371
371
|
.slice(0, 60);
|
|
372
372
|
}
|
|
373
|
+
|
|
374
|
+
// Re-export the system-message injector so callers that import the
|
|
375
|
+
// hosted module get the full memory-augmentation surface in one place.
|
|
376
|
+
// Keeping the implementation in `./inject.js` lets non-hosted consumers
|
|
377
|
+
// (e.g. a future "augment a request body" helper that doesn't talk to
|
|
378
|
+
// TES) reuse it without pulling in the GraphQL surface.
|
|
379
|
+
export { injectMemories } from "./inject.js";
|
|
@@ -21,7 +21,17 @@ import { distill } from "./distill.js";
|
|
|
21
21
|
* tasks (e.g. Cloudflare Worker ctx.waitUntil). If provided, the distill
|
|
22
22
|
* background task is handed to it so the host keeps it alive past return.
|
|
23
23
|
* Without it, distill is fire-and-forget (fine for Node/browser).
|
|
24
|
-
* @
|
|
24
|
+
* @param {boolean} [opts.dedup=false] - Skip ingest if a memory_node with
|
|
25
|
+
* byte-equal content already exists for this `client_id`. Use for
|
|
26
|
+
* retry-safe pipelines where the same logical event may be processed
|
|
27
|
+
* twice (queue retries, consumer fan-out). Returns the existing row's
|
|
28
|
+
* id with `{deduped: true}` instead of inserting. Strict equality —
|
|
29
|
+
* not a semantic similarity match. Best-effort: if the SELECT itself
|
|
30
|
+
* fails, ingest proceeds (worst case: duplicate row, identical to
|
|
31
|
+
* `dedup:false` behaviour). The eventual structural fix is a
|
|
32
|
+
* `UNIQUE(client_id, content_hash)` constraint at the schema level;
|
|
33
|
+
* this option is the bridge.
|
|
34
|
+
* @returns {Promise<{id: string, content: string, layerId: string, deduped?: boolean}>}
|
|
25
35
|
*/
|
|
26
36
|
export async function ingest(db, ai, llm, content, opts = {}) {
|
|
27
37
|
const clientId = opts.clientId;
|
|
@@ -41,6 +51,35 @@ export async function ingest(db, ai, llm, content, opts = {}) {
|
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
const layerId = layerResult.rows[0].id;
|
|
54
|
+
|
|
55
|
+
// Optional dedup: skip the insert (and all the embedding/HyDE/distill
|
|
56
|
+
// work that would follow) if a row with byte-equal content already
|
|
57
|
+
// exists for this tenant. The OR-LIKE branch matches against the
|
|
58
|
+
// legacy `[<iso>] <content>` form so callers that wrote with a
|
|
59
|
+
// timestamp prefix dedup correctly until the legacy corpus ages out.
|
|
60
|
+
if (opts.dedup) {
|
|
61
|
+
try {
|
|
62
|
+
const dupCheck = await db(
|
|
63
|
+
`SELECT id FROM memory_nodes
|
|
64
|
+
WHERE client_id = $1
|
|
65
|
+
AND (content = $2 OR content LIKE '%] ' || $2)
|
|
66
|
+
LIMIT 1`,
|
|
67
|
+
[clientId, content]
|
|
68
|
+
);
|
|
69
|
+
if (dupCheck.rows?.length) {
|
|
70
|
+
log(`dedup: matched existing memory ${dupCheck.rows[0].id}`);
|
|
71
|
+
return {
|
|
72
|
+
id: dupCheck.rows[0].id,
|
|
73
|
+
content,
|
|
74
|
+
layerId,
|
|
75
|
+
deduped: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log(`dedup check failed (proceeding with insert): ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
44
83
|
const memoryId = `mem_${crypto.randomUUID()}`;
|
|
45
84
|
|
|
46
85
|
// Insert memory node
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory injection — formats retrieved memories as a system-message preamble
|
|
3
|
+
* and merges them into the upstream request body.
|
|
4
|
+
*
|
|
5
|
+
* Why a preamble (not a separate user-turn or tool-result):
|
|
6
|
+
* - Customer's existing system prompt is preserved verbatim, just appended.
|
|
7
|
+
* - Anthropic and OpenAI both treat system content as cache-friendly.
|
|
8
|
+
* - No conversation-history mutation — replays remain reproducible.
|
|
9
|
+
*
|
|
10
|
+
* Format:
|
|
11
|
+
* <tes:context>
|
|
12
|
+
* [1] (similarity 0.82) memory text...
|
|
13
|
+
* [2] (similarity 0.71) memory text...
|
|
14
|
+
* </tes:context>
|
|
15
|
+
*
|
|
16
|
+
* The XML-ish wrapper makes it trivial for the model to ignore on demand
|
|
17
|
+
* and trivial for an evaluator to strip when measuring quality deltas.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MAX_CHARS_PER_MEMORY = 1200;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} body — upstream request body, mutated copy returned
|
|
24
|
+
* @param {Array<{id, content, similarity}>} memories
|
|
25
|
+
* @param {"anthropic"|"openai"} provider
|
|
26
|
+
* @returns {object} new body
|
|
27
|
+
*/
|
|
28
|
+
export function injectMemories(body, memories, provider) {
|
|
29
|
+
if (!memories || memories.length === 0) return body;
|
|
30
|
+
|
|
31
|
+
const preamble = formatPreamble(memories);
|
|
32
|
+
|
|
33
|
+
if (provider === "anthropic") {
|
|
34
|
+
return injectAnthropic(body, preamble);
|
|
35
|
+
}
|
|
36
|
+
return injectOpenAI(body, preamble);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatPreamble(memories) {
|
|
40
|
+
const lines = ["<tes:context>"];
|
|
41
|
+
memories.forEach((m, i) => {
|
|
42
|
+
const sim =
|
|
43
|
+
typeof m.similarity === "number" ? m.similarity.toFixed(2) : "?";
|
|
44
|
+
const content = (m.content || "").slice(0, MAX_CHARS_PER_MEMORY);
|
|
45
|
+
lines.push(`[${i + 1}] (similarity ${sim}) ${content}`);
|
|
46
|
+
});
|
|
47
|
+
lines.push("</tes:context>");
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function injectAnthropic(body, preamble) {
|
|
52
|
+
// Anthropic accepts `system` as either a string OR an array of content
|
|
53
|
+
// blocks. Preserve whichever shape the customer sent.
|
|
54
|
+
const next = { ...body };
|
|
55
|
+
if (typeof body.system === "string") {
|
|
56
|
+
next.system = `${preamble}\n\n${body.system}`;
|
|
57
|
+
} else if (Array.isArray(body.system)) {
|
|
58
|
+
next.system = [{ type: "text", text: preamble }, ...body.system];
|
|
59
|
+
} else {
|
|
60
|
+
next.system = preamble;
|
|
61
|
+
}
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function injectOpenAI(body, preamble) {
|
|
66
|
+
// OpenAI carries the system prompt as the first message with role:'system'.
|
|
67
|
+
// If one exists we prepend; otherwise we insert a fresh one at index 0.
|
|
68
|
+
const messages = Array.isArray(body.messages) ? [...body.messages] : [];
|
|
69
|
+
if (messages.length > 0 && messages[0].role === "system") {
|
|
70
|
+
const existing = messages[0];
|
|
71
|
+
const existingContent =
|
|
72
|
+
typeof existing.content === "string"
|
|
73
|
+
? existing.content
|
|
74
|
+
: JSON.stringify(existing.content);
|
|
75
|
+
messages[0] = {
|
|
76
|
+
...existing,
|
|
77
|
+
content: `${preamble}\n\n${existingContent}`,
|
|
78
|
+
};
|
|
79
|
+
} else {
|
|
80
|
+
messages.unshift({ role: "system", content: preamble });
|
|
81
|
+
}
|
|
82
|
+
return { ...body, messages };
|
|
83
|
+
}
|
package/src/client.js
CHANGED
|
@@ -56,8 +56,26 @@ export class TESClient {
|
|
|
56
56
|
return new Session(this._config, opts);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
wrap(
|
|
59
|
+
wrap(
|
|
60
|
+
client,
|
|
61
|
+
{
|
|
62
|
+
sessionId,
|
|
63
|
+
userId,
|
|
64
|
+
metadata,
|
|
65
|
+
autoEmit = true,
|
|
66
|
+
waitUntil,
|
|
67
|
+
memory,
|
|
68
|
+
memoryOpts,
|
|
69
|
+
} = {}
|
|
70
|
+
) {
|
|
60
71
|
const config = userId ? { ...this._config, userId } : this._config;
|
|
61
|
-
return wrapClient(config, client, {
|
|
72
|
+
return wrapClient(config, client, {
|
|
73
|
+
sessionId,
|
|
74
|
+
metadata,
|
|
75
|
+
autoEmit,
|
|
76
|
+
waitUntil,
|
|
77
|
+
memory,
|
|
78
|
+
memoryOpts,
|
|
79
|
+
});
|
|
62
80
|
}
|
|
63
81
|
}
|