neo4j-agent-memory 0.3.17
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/README.md +161 -0
- package/dist/cypher/feedback_batch.cypher +42 -0
- package/dist/cypher/feedback_co_used_with_batch.cypher +34 -0
- package/dist/cypher/index.ts +23 -0
- package/dist/cypher/list_memories.cypher +39 -0
- package/dist/cypher/relate_concepts.cypher +13 -0
- package/dist/cypher/retrieve_context_bundle.cypher +555 -0
- package/dist/cypher/schema.cypher +20 -0
- package/dist/cypher/upsert_case.cypher +61 -0
- package/dist/cypher/upsert_memory.cypher +38 -0
- package/dist/index.cjs +789 -0
- package/dist/index.d.ts +320 -0
- package/dist/index.js +738 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
// src/neo4j/client.ts
|
|
2
|
+
import neo4j from "neo4j-driver";
|
|
3
|
+
var Neo4jClient = class {
|
|
4
|
+
driver;
|
|
5
|
+
database;
|
|
6
|
+
constructor(cfg) {
|
|
7
|
+
this.driver = neo4j.driver(cfg.uri, neo4j.auth.basic(cfg.username, cfg.password));
|
|
8
|
+
this.database = cfg.database;
|
|
9
|
+
}
|
|
10
|
+
session(mode = "READ") {
|
|
11
|
+
return this.driver.session({
|
|
12
|
+
database: this.database,
|
|
13
|
+
defaultAccessMode: mode === "READ" ? neo4j.session.READ : neo4j.session.WRITE
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async close() {
|
|
17
|
+
await this.driver.close();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/cypher/index.ts
|
|
22
|
+
import { readFileSync } from "fs";
|
|
23
|
+
import { fileURLToPath } from "url";
|
|
24
|
+
import path from "path";
|
|
25
|
+
var resolvedDir = typeof __dirname === "string" ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
var baseDir = path.basename(resolvedDir) === "cypher" ? resolvedDir : path.resolve(resolvedDir, "cypher");
|
|
27
|
+
function loadCypher(rel) {
|
|
28
|
+
return readFileSync(path.resolve(baseDir, rel), "utf8");
|
|
29
|
+
}
|
|
30
|
+
var cypher = {
|
|
31
|
+
schema: loadCypher("schema.cypher"),
|
|
32
|
+
upsertMemory: loadCypher("upsert_memory.cypher"),
|
|
33
|
+
upsertCase: loadCypher("upsert_case.cypher"),
|
|
34
|
+
retrieveContextBundle: loadCypher("retrieve_context_bundle.cypher"),
|
|
35
|
+
feedbackBatch: loadCypher("feedback_batch.cypher"),
|
|
36
|
+
feedbackCoUsed: loadCypher("feedback_co_used_with_batch.cypher"),
|
|
37
|
+
listMemories: loadCypher("list_memories.cypher"),
|
|
38
|
+
relateConcepts: loadCypher("relate_concepts.cypher")
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// src/neo4j/schema.ts
|
|
42
|
+
var schemaVersion = 1;
|
|
43
|
+
async function ensureSchema(client) {
|
|
44
|
+
const session = client.session("WRITE");
|
|
45
|
+
try {
|
|
46
|
+
const statements = cypher.schema.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean);
|
|
47
|
+
for (const stmt of statements) {
|
|
48
|
+
await session.run(stmt);
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
await session.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function migrate(client, targetVersion = schemaVersion) {
|
|
55
|
+
if (targetVersion !== schemaVersion) {
|
|
56
|
+
throw new Error(`Unsupported schema version ${targetVersion}; current is ${schemaVersion}`);
|
|
57
|
+
}
|
|
58
|
+
await ensureSchema(client);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/utils/hash.ts
|
|
62
|
+
import { createHash, randomUUID } from "crypto";
|
|
63
|
+
function sha256Hex(s) {
|
|
64
|
+
return createHash("sha256").update(s, "utf8").digest("hex");
|
|
65
|
+
}
|
|
66
|
+
var norm = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
|
|
67
|
+
function canonicaliseForHash(title, content, tags) {
|
|
68
|
+
const tagNorm = [...new Set(tags.map(norm))].sort().join(",");
|
|
69
|
+
return `${norm(title)}
|
|
70
|
+
${norm(content)}
|
|
71
|
+
${tagNorm}`;
|
|
72
|
+
}
|
|
73
|
+
function newId(prefix) {
|
|
74
|
+
return `${prefix}_${randomUUID()}`;
|
|
75
|
+
}
|
|
76
|
+
function normaliseSymptom(s) {
|
|
77
|
+
return norm(s);
|
|
78
|
+
}
|
|
79
|
+
function envHash(env) {
|
|
80
|
+
const payload = {
|
|
81
|
+
os: env.os ?? null,
|
|
82
|
+
distro: env.distro ?? null,
|
|
83
|
+
ci: env.ci ?? null,
|
|
84
|
+
container: env.container ?? null,
|
|
85
|
+
filesystem: env.filesystem ?? null,
|
|
86
|
+
workspaceMount: env.workspaceMount ?? null,
|
|
87
|
+
nodeVersion: env.nodeVersion ?? null,
|
|
88
|
+
packageManager: env.packageManager ?? null,
|
|
89
|
+
pmVersion: env.pmVersion ?? null
|
|
90
|
+
};
|
|
91
|
+
return sha256Hex(JSON.stringify(payload));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/memory_service.ts
|
|
95
|
+
function clamp01(x) {
|
|
96
|
+
return Math.max(0, Math.min(1, x));
|
|
97
|
+
}
|
|
98
|
+
function toBetaEdge(raw) {
|
|
99
|
+
const aMin = 1e-3;
|
|
100
|
+
const bMin = 1e-3;
|
|
101
|
+
const strength = typeof raw?.strength === "number" ? raw.strength : 0.5;
|
|
102
|
+
const a = typeof raw?.a === "number" ? raw.a : Math.max(aMin, strength * 2);
|
|
103
|
+
const b = typeof raw?.b === "number" ? raw.b : Math.max(bMin, (1 - strength) * 2);
|
|
104
|
+
const ev = typeof raw?.evidence === "number" ? raw.evidence : a + b;
|
|
105
|
+
return {
|
|
106
|
+
a,
|
|
107
|
+
b,
|
|
108
|
+
strength: typeof raw?.strength === "number" ? raw.strength : a / (a + b),
|
|
109
|
+
evidence: ev,
|
|
110
|
+
updatedAt: raw?.updatedAt ?? null
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function defaultPolicy(req) {
|
|
114
|
+
return {
|
|
115
|
+
minConfidence: req?.minConfidence ?? 0.65,
|
|
116
|
+
requireVerificationSteps: req?.requireVerificationSteps ?? true,
|
|
117
|
+
maxItems: req?.maxItems ?? 5
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function detectSecrets(text) {
|
|
121
|
+
const suspicious = /(AKIA[0-9A-Z]{16}|-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----|xox[baprs]-|ghp_[A-Za-z0-9]{36,})/;
|
|
122
|
+
return suspicious.test(text);
|
|
123
|
+
}
|
|
124
|
+
function validateLearning(l, pol) {
|
|
125
|
+
if (!l.title || l.title.trim().length < 4) return "title too short";
|
|
126
|
+
if (!l.content || l.content.trim().length < 20) return "content too short";
|
|
127
|
+
if ((l.tags ?? []).length < 1) return "missing tags";
|
|
128
|
+
if (!(l.confidence >= 0 && l.confidence <= 1)) return "confidence must be 0..1";
|
|
129
|
+
if (l.confidence < pol.minConfidence) return `confidence < ${pol.minConfidence}`;
|
|
130
|
+
if (detectSecrets(l.content)) return "possible secret detected";
|
|
131
|
+
if (l.kind === "procedural" && pol.requireVerificationSteps) {
|
|
132
|
+
const v = l.triage?.verificationSteps?.length ?? 0;
|
|
133
|
+
const f = l.triage?.fixSteps?.length ?? 0;
|
|
134
|
+
if (v < 1) return "procedural requires triage.verificationSteps";
|
|
135
|
+
if (f < 1) return "procedural requires triage.fixSteps";
|
|
136
|
+
}
|
|
137
|
+
const polarity = l.polarity ?? "positive";
|
|
138
|
+
if (polarity === "negative") {
|
|
139
|
+
if (!l.antiPattern?.action || !l.antiPattern?.whyBad) {
|
|
140
|
+
return "negative memories require antiPattern.action + antiPattern.whyBad";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
function formatEpisodeContent(args, title) {
|
|
146
|
+
const lines = [
|
|
147
|
+
`Title: ${title}`,
|
|
148
|
+
`Run: ${args.runId}`,
|
|
149
|
+
`Workflow: ${args.workflowName}`,
|
|
150
|
+
`Outcome: ${args.outcome ?? "unknown"}`,
|
|
151
|
+
"",
|
|
152
|
+
"Prompt:",
|
|
153
|
+
args.prompt.trim(),
|
|
154
|
+
"",
|
|
155
|
+
"Response:",
|
|
156
|
+
args.response.trim()
|
|
157
|
+
];
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|
|
160
|
+
function buildEpisodeLearning(args, title) {
|
|
161
|
+
return {
|
|
162
|
+
kind: "episodic",
|
|
163
|
+
title,
|
|
164
|
+
content: formatEpisodeContent(args, title),
|
|
165
|
+
tags: args.tags ?? [],
|
|
166
|
+
confidence: 0.7
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
var MemoryService = class {
|
|
170
|
+
client;
|
|
171
|
+
vectorIndex;
|
|
172
|
+
fulltextIndex;
|
|
173
|
+
halfLifeSeconds;
|
|
174
|
+
onMemoryEvent;
|
|
175
|
+
cyUpsertMemory = cypher.upsertMemory;
|
|
176
|
+
cyUpsertCase = cypher.upsertCase;
|
|
177
|
+
cyRetrieveBundle = cypher.retrieveContextBundle;
|
|
178
|
+
cyFeedbackBatch = cypher.feedbackBatch;
|
|
179
|
+
cyFeedbackCoUsed = cypher.feedbackCoUsed;
|
|
180
|
+
cyListMemories = cypher.listMemories;
|
|
181
|
+
cyRelateConcepts = cypher.relateConcepts;
|
|
182
|
+
cyGetRecallEdges = `
|
|
183
|
+
UNWIND $ids AS id
|
|
184
|
+
MATCH (m:Memory {id:id})
|
|
185
|
+
OPTIONAL MATCH (a:Agent {id:$agentId})-[r:RECALLS]->(m)
|
|
186
|
+
RETURN id AS id,
|
|
187
|
+
r.a AS a,
|
|
188
|
+
r.b AS b,
|
|
189
|
+
r.strength AS strength,
|
|
190
|
+
r.evidence AS evidence,
|
|
191
|
+
toString(r.updatedAt) AS updatedAt
|
|
192
|
+
`;
|
|
193
|
+
constructor(cfg) {
|
|
194
|
+
this.client = new Neo4jClient(cfg.neo4j);
|
|
195
|
+
this.vectorIndex = cfg.vectorIndex ?? "memoryEmbedding";
|
|
196
|
+
this.fulltextIndex = cfg.fulltextIndex ?? "memoryText";
|
|
197
|
+
this.halfLifeSeconds = cfg.halfLifeSeconds ?? 30 * 24 * 3600;
|
|
198
|
+
this.onMemoryEvent = cfg.onMemoryEvent;
|
|
199
|
+
}
|
|
200
|
+
async init() {
|
|
201
|
+
await ensureSchema(this.client);
|
|
202
|
+
}
|
|
203
|
+
async close() {
|
|
204
|
+
await this.client.close();
|
|
205
|
+
}
|
|
206
|
+
ensureEnvHash(env) {
|
|
207
|
+
const e = env ?? {};
|
|
208
|
+
if (!e.hash) e.hash = envHash(e);
|
|
209
|
+
return e;
|
|
210
|
+
}
|
|
211
|
+
emit(event) {
|
|
212
|
+
if (!this.onMemoryEvent) return;
|
|
213
|
+
try {
|
|
214
|
+
this.onMemoryEvent({ ...event, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Save a distilled memory (semantic/procedural/episodic) with exact dedupe by contentHash.
|
|
220
|
+
* NOTE: This package intentionally does not store "full answers" as semantic/procedural.
|
|
221
|
+
*/
|
|
222
|
+
async upsertMemory(l) {
|
|
223
|
+
const id = l.id ?? newId("mem");
|
|
224
|
+
const polarity = l.polarity ?? "positive";
|
|
225
|
+
const tags = [...new Set((l.tags ?? []).map((t) => t.trim()).filter(Boolean))];
|
|
226
|
+
const canonical = canonicaliseForHash(l.title, l.content, tags);
|
|
227
|
+
const contentHash = sha256Hex(canonical);
|
|
228
|
+
const read = this.client.session("READ");
|
|
229
|
+
try {
|
|
230
|
+
const existing = await read.run(
|
|
231
|
+
"MATCH (m:Memory {contentHash: $h}) RETURN m.id AS id LIMIT 1",
|
|
232
|
+
{ h: contentHash }
|
|
233
|
+
);
|
|
234
|
+
if (existing.records.length > 0) {
|
|
235
|
+
const existingId = existing.records[0].get("id");
|
|
236
|
+
this.emit({ type: "write", action: "upsertMemory.dedupe", meta: { id: existingId } });
|
|
237
|
+
return { id: existingId, deduped: true };
|
|
238
|
+
}
|
|
239
|
+
} finally {
|
|
240
|
+
await read.close();
|
|
241
|
+
}
|
|
242
|
+
const write = this.client.session("WRITE");
|
|
243
|
+
try {
|
|
244
|
+
await write.run(this.cyUpsertMemory, {
|
|
245
|
+
id,
|
|
246
|
+
kind: l.kind,
|
|
247
|
+
polarity,
|
|
248
|
+
title: l.title,
|
|
249
|
+
content: l.content,
|
|
250
|
+
contentHash,
|
|
251
|
+
tags,
|
|
252
|
+
confidence: clamp01(l.confidence),
|
|
253
|
+
utility: 0.2,
|
|
254
|
+
// start modest; reinforce via feedback
|
|
255
|
+
triage: l.triage ? JSON.stringify(l.triage) : null,
|
|
256
|
+
antiPattern: l.antiPattern ? JSON.stringify(l.antiPattern) : null
|
|
257
|
+
});
|
|
258
|
+
if (l.env) {
|
|
259
|
+
const env = this.ensureEnvHash(l.env);
|
|
260
|
+
await write.run(
|
|
261
|
+
`MERGE (e:EnvironmentFingerprint {hash:$hash})
|
|
262
|
+
ON CREATE SET e.os=$os, e.distro=$distro, e.ci=$ci, e.container=$container,
|
|
263
|
+
e.filesystem=$filesystem, e.workspaceMount=$workspaceMount,
|
|
264
|
+
e.nodeVersion=$nodeVersion, e.packageManager=$packageManager, e.pmVersion=$pmVersion
|
|
265
|
+
WITH e
|
|
266
|
+
MATCH (m:Memory {id:$id})
|
|
267
|
+
MERGE (m)-[:APPLIES_IN]->(e)`,
|
|
268
|
+
{
|
|
269
|
+
id,
|
|
270
|
+
hash: env.hash,
|
|
271
|
+
os: env.os ?? null,
|
|
272
|
+
distro: env.distro ?? null,
|
|
273
|
+
ci: env.ci ?? null,
|
|
274
|
+
container: env.container ?? null,
|
|
275
|
+
filesystem: env.filesystem ?? null,
|
|
276
|
+
workspaceMount: env.workspaceMount ?? null,
|
|
277
|
+
nodeVersion: env.nodeVersion ?? null,
|
|
278
|
+
packageManager: env.packageManager ?? null,
|
|
279
|
+
pmVersion: env.pmVersion ?? null
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
this.emit({ type: "write", action: "upsertMemory", meta: { id } });
|
|
284
|
+
return { id, deduped: false };
|
|
285
|
+
} finally {
|
|
286
|
+
await write.close();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Upsert an episodic Case (case-based reasoning) that links symptoms + env + resolved_by + negative memories.
|
|
291
|
+
*/
|
|
292
|
+
async upsertCase(c) {
|
|
293
|
+
const env = this.ensureEnvHash(c.env);
|
|
294
|
+
const symptoms = [...new Set((c.symptoms ?? []).map(normaliseSymptom).filter(Boolean))];
|
|
295
|
+
const session = this.client.session("WRITE");
|
|
296
|
+
try {
|
|
297
|
+
const res = await session.run(this.cyUpsertCase, {
|
|
298
|
+
caseId: c.id,
|
|
299
|
+
title: c.title,
|
|
300
|
+
summary: c.summary,
|
|
301
|
+
outcome: c.outcome,
|
|
302
|
+
symptoms,
|
|
303
|
+
env,
|
|
304
|
+
resolvedByMemoryIds: c.resolvedByMemoryIds ?? [],
|
|
305
|
+
negativeMemoryIds: c.negativeMemoryIds ?? [],
|
|
306
|
+
resolvedAtIso: c.resolvedAtIso ?? null
|
|
307
|
+
});
|
|
308
|
+
const caseId = res.records[0].get("caseId");
|
|
309
|
+
this.emit({ type: "write", action: "upsertCase", meta: { caseId } });
|
|
310
|
+
return caseId;
|
|
311
|
+
} finally {
|
|
312
|
+
await session.close();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Retrieve a ContextBundle with separate Fix and Do-not-do sections, using case-based reasoning.
|
|
317
|
+
* The key idea: match cases by symptoms + env similarity, then pull linked memories.
|
|
318
|
+
*/
|
|
319
|
+
async retrieveContextBundle(args) {
|
|
320
|
+
const nowIso = args.nowIso ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
321
|
+
const symptoms = [...new Set((args.symptoms ?? []).map(normaliseSymptom).filter(Boolean))];
|
|
322
|
+
const env = this.ensureEnvHash(args.env ?? {});
|
|
323
|
+
const caseLimit = args.caseLimit ?? 5;
|
|
324
|
+
const fixLimit = args.fixLimit ?? 8;
|
|
325
|
+
const dontLimit = args.dontLimit ?? 6;
|
|
326
|
+
const session = this.client.session("READ");
|
|
327
|
+
try {
|
|
328
|
+
const r = await session.run(this.cyRetrieveBundle, {
|
|
329
|
+
agentId: args.agentId,
|
|
330
|
+
symptoms,
|
|
331
|
+
tags: args.tags ?? [],
|
|
332
|
+
env,
|
|
333
|
+
caseLimit,
|
|
334
|
+
fixLimit,
|
|
335
|
+
dontLimit,
|
|
336
|
+
nowIso,
|
|
337
|
+
halfLifeSeconds: this.halfLifeSeconds
|
|
338
|
+
});
|
|
339
|
+
const sections = r.records[0].get("sections");
|
|
340
|
+
const fixes = (sections.fixes ?? []).map((m) => ({
|
|
341
|
+
id: m.id,
|
|
342
|
+
kind: m.kind,
|
|
343
|
+
polarity: m.polarity ?? "positive",
|
|
344
|
+
title: m.title,
|
|
345
|
+
content: m.content,
|
|
346
|
+
tags: m.tags ?? [],
|
|
347
|
+
confidence: m.confidence ?? 0.7,
|
|
348
|
+
utility: m.utility ?? 0.2,
|
|
349
|
+
updatedAt: m.updatedAt?.toString?.() ?? null
|
|
350
|
+
}));
|
|
351
|
+
const doNot = (sections.doNot ?? []).map((m) => ({
|
|
352
|
+
id: m.id,
|
|
353
|
+
kind: m.kind,
|
|
354
|
+
polarity: m.polarity ?? "negative",
|
|
355
|
+
title: m.title,
|
|
356
|
+
content: m.content,
|
|
357
|
+
tags: m.tags ?? [],
|
|
358
|
+
confidence: m.confidence ?? 0.7,
|
|
359
|
+
utility: m.utility ?? 0.2,
|
|
360
|
+
updatedAt: m.updatedAt?.toString?.() ?? null
|
|
361
|
+
}));
|
|
362
|
+
const allIds = [.../* @__PURE__ */ new Set([...fixes.map((x) => x.id), ...doNot.map((x) => x.id)])];
|
|
363
|
+
const edgeAfter = /* @__PURE__ */ new Map();
|
|
364
|
+
if (allIds.length > 0) {
|
|
365
|
+
const edgeRes = await session.run(this.cyGetRecallEdges, { agentId: args.agentId, ids: allIds });
|
|
366
|
+
for (const rec of edgeRes.records) {
|
|
367
|
+
const id = rec.get("id");
|
|
368
|
+
edgeAfter.set(id, {
|
|
369
|
+
a: rec.get("a"),
|
|
370
|
+
b: rec.get("b"),
|
|
371
|
+
strength: rec.get("strength"),
|
|
372
|
+
evidence: rec.get("evidence"),
|
|
373
|
+
updatedAt: rec.get("updatedAt")
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const fixesWithEdges = fixes.map((m) => ({
|
|
378
|
+
...m,
|
|
379
|
+
edgeBefore: toBetaEdge(edgeAfter.get(m.id)),
|
|
380
|
+
edgeAfter: void 0
|
|
381
|
+
}));
|
|
382
|
+
const doNotWithEdges = doNot.map((m) => ({
|
|
383
|
+
...m,
|
|
384
|
+
edgeBefore: toBetaEdge(edgeAfter.get(m.id)),
|
|
385
|
+
edgeAfter: void 0
|
|
386
|
+
}));
|
|
387
|
+
const sessionId = newId("session");
|
|
388
|
+
const fixBlock = "## Recommended fixes\n" + fixesWithEdges.map((m) => `
|
|
389
|
+
|
|
390
|
+
### [MEM:${m.id}] ${m.title}
|
|
391
|
+
${m.content}`).join("");
|
|
392
|
+
const doNotDoBlock = "## Do not do\n" + doNotWithEdges.map((m) => `
|
|
393
|
+
|
|
394
|
+
### [MEM:${m.id}] ${m.title}
|
|
395
|
+
${m.content}`).join("");
|
|
396
|
+
this.emit({
|
|
397
|
+
type: "read",
|
|
398
|
+
action: "retrieveContextBundle",
|
|
399
|
+
meta: { sessionId, fixCount: fixesWithEdges.length, doNotCount: doNotWithEdges.length }
|
|
400
|
+
});
|
|
401
|
+
return {
|
|
402
|
+
sessionId,
|
|
403
|
+
sections: { fix: fixesWithEdges, doNotDo: doNotWithEdges },
|
|
404
|
+
injection: { fixBlock, doNotDoBlock }
|
|
405
|
+
};
|
|
406
|
+
} finally {
|
|
407
|
+
await session.close();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async listMemories(args = {}) {
|
|
411
|
+
const session = this.client.session("READ");
|
|
412
|
+
try {
|
|
413
|
+
const res = await session.run(this.cyListMemories, {
|
|
414
|
+
kind: args.kind ?? null,
|
|
415
|
+
limit: args.limit ?? 25,
|
|
416
|
+
agentId: args.agentId ?? null
|
|
417
|
+
});
|
|
418
|
+
const memories = res.records[0]?.get("memories") ?? [];
|
|
419
|
+
const summaries = memories.map((m) => ({
|
|
420
|
+
id: m.id,
|
|
421
|
+
kind: m.kind,
|
|
422
|
+
polarity: m.polarity ?? "positive",
|
|
423
|
+
title: m.title,
|
|
424
|
+
tags: m.tags ?? [],
|
|
425
|
+
confidence: m.confidence ?? 0.7,
|
|
426
|
+
utility: m.utility ?? 0.2,
|
|
427
|
+
createdAt: m.createdAt?.toString?.() ?? null,
|
|
428
|
+
updatedAt: m.updatedAt?.toString?.() ?? null
|
|
429
|
+
}));
|
|
430
|
+
this.emit({ type: "read", action: "listMemories", meta: { count: summaries.length, kind: args.kind } });
|
|
431
|
+
return summaries;
|
|
432
|
+
} finally {
|
|
433
|
+
await session.close();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async listEpisodes(args = {}) {
|
|
437
|
+
return this.listMemories({ ...args, kind: "episodic" });
|
|
438
|
+
}
|
|
439
|
+
async listSkills(args = {}) {
|
|
440
|
+
return this.listMemories({ ...args, kind: "procedural" });
|
|
441
|
+
}
|
|
442
|
+
async listConcepts(args = {}) {
|
|
443
|
+
return this.listMemories({ ...args, kind: "semantic" });
|
|
444
|
+
}
|
|
445
|
+
async relateConcepts(args) {
|
|
446
|
+
const weight = typeof args.weight === "number" ? args.weight : 0.5;
|
|
447
|
+
const session = this.client.session("WRITE");
|
|
448
|
+
try {
|
|
449
|
+
await session.run(this.cyRelateConcepts, { a: args.sourceId, b: args.targetId, weight });
|
|
450
|
+
this.emit({ type: "write", action: "relateConcepts", meta: { sourceId: args.sourceId, targetId: args.targetId } });
|
|
451
|
+
} finally {
|
|
452
|
+
await session.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async captureEpisode(args) {
|
|
456
|
+
const title = `Episode ${args.workflowName} (${args.runId})`;
|
|
457
|
+
const learning = buildEpisodeLearning(args, title);
|
|
458
|
+
const result = await this.saveLearnings({
|
|
459
|
+
agentId: args.agentId,
|
|
460
|
+
sessionId: args.runId,
|
|
461
|
+
learnings: [learning]
|
|
462
|
+
});
|
|
463
|
+
this.emit({ type: "write", action: "captureEpisode", meta: { runId: args.runId, title } });
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
async captureStepEpisode(args) {
|
|
467
|
+
const title = `Episode ${args.workflowName} - ${args.stepName}`;
|
|
468
|
+
const base = {
|
|
469
|
+
agentId: args.agentId,
|
|
470
|
+
runId: args.runId,
|
|
471
|
+
workflowName: args.workflowName,
|
|
472
|
+
prompt: args.prompt,
|
|
473
|
+
response: args.response,
|
|
474
|
+
outcome: args.outcome,
|
|
475
|
+
tags: args.tags
|
|
476
|
+
};
|
|
477
|
+
const learning = buildEpisodeLearning(base, title);
|
|
478
|
+
const result = await this.saveLearnings({
|
|
479
|
+
agentId: args.agentId,
|
|
480
|
+
sessionId: args.runId,
|
|
481
|
+
learnings: [learning]
|
|
482
|
+
});
|
|
483
|
+
this.emit({ type: "write", action: "captureStepEpisode", meta: { runId: args.runId, stepName: args.stepName } });
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Reinforce/degrade agent->memory association weights using a single batched Cypher query.
|
|
488
|
+
* This supports mid-run retrieval by making feedback cheap and frequent.
|
|
489
|
+
*/
|
|
490
|
+
async feedback(fb) {
|
|
491
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
492
|
+
const used = new Set(fb.usedIds ?? []);
|
|
493
|
+
const useful = new Set(fb.usefulIds ?? []);
|
|
494
|
+
const notUseful = new Set(fb.notUsefulIds ?? []);
|
|
495
|
+
const prevented = new Set(fb.preventedErrorIds ?? []);
|
|
496
|
+
for (const id of prevented) useful.add(id);
|
|
497
|
+
for (const id of useful) notUseful.delete(id);
|
|
498
|
+
for (const id of useful) used.add(id);
|
|
499
|
+
for (const id of notUseful) used.add(id);
|
|
500
|
+
const quality = clamp01(fb.metrics?.quality ?? 0.7);
|
|
501
|
+
const hallucRisk = clamp01(fb.metrics?.hallucinationRisk ?? 0.2);
|
|
502
|
+
const baseY = clamp01(quality - 0.7 * hallucRisk);
|
|
503
|
+
const w = 0.5 + 1.5 * quality;
|
|
504
|
+
const yById = /* @__PURE__ */ new Map();
|
|
505
|
+
for (const id of used) {
|
|
506
|
+
yById.set(id, useful.has(id) ? baseY : 0);
|
|
507
|
+
}
|
|
508
|
+
const items = [...used].map((memoryId) => ({
|
|
509
|
+
memoryId,
|
|
510
|
+
y: yById.get(memoryId) ?? 0,
|
|
511
|
+
w
|
|
512
|
+
}));
|
|
513
|
+
if (items.length === 0) return;
|
|
514
|
+
const session = this.client.session("WRITE");
|
|
515
|
+
try {
|
|
516
|
+
await session.run("MERGE (a:Agent {id:$id}) RETURN a", { id: fb.agentId });
|
|
517
|
+
await session.run(this.cyFeedbackBatch, {
|
|
518
|
+
agentId: fb.agentId,
|
|
519
|
+
nowIso,
|
|
520
|
+
items,
|
|
521
|
+
halfLifeSeconds: this.halfLifeSeconds,
|
|
522
|
+
aMin: 1e-3,
|
|
523
|
+
bMin: 1e-3
|
|
524
|
+
});
|
|
525
|
+
const ids = [...used];
|
|
526
|
+
const pairs = [];
|
|
527
|
+
for (let i = 0; i < ids.length; i++) {
|
|
528
|
+
for (let j = i + 1; j < ids.length; j++) {
|
|
529
|
+
const a = ids[i] < ids[j] ? ids[i] : ids[j];
|
|
530
|
+
const b = ids[i] < ids[j] ? ids[j] : ids[i];
|
|
531
|
+
const yA = yById.get(a) ?? 0;
|
|
532
|
+
const yB = yById.get(b) ?? 0;
|
|
533
|
+
pairs.push({ a, b, y: Math.min(yA, yB), w });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (pairs.length > 0) {
|
|
537
|
+
await session.run(this.cyFeedbackCoUsed, {
|
|
538
|
+
nowIso,
|
|
539
|
+
pairs,
|
|
540
|
+
halfLifeSeconds: this.halfLifeSeconds,
|
|
541
|
+
aMin: 1e-3,
|
|
542
|
+
bMin: 1e-3
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
this.emit({ type: "write", action: "feedback", meta: { agentId: fb.agentId, usedCount: used.size } });
|
|
546
|
+
} finally {
|
|
547
|
+
await session.close();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Save distilled learnings discovered during a task.
|
|
552
|
+
* Enforces quality gates and stores negative memories explicitly.
|
|
553
|
+
* Automatically creates a Case if learnings have triage.symptoms.
|
|
554
|
+
*/
|
|
555
|
+
async saveLearnings(req) {
|
|
556
|
+
const pol = defaultPolicy(req.policy);
|
|
557
|
+
const limited = (req.learnings ?? []).slice(0, pol.maxItems);
|
|
558
|
+
const saved = [];
|
|
559
|
+
const rejected = [];
|
|
560
|
+
const positiveMemoryIds = [];
|
|
561
|
+
const negativeMemoryIds = [];
|
|
562
|
+
let firstEnv;
|
|
563
|
+
let allSymptoms = [];
|
|
564
|
+
for (const l of limited) {
|
|
565
|
+
const reason = validateLearning(l, pol);
|
|
566
|
+
if (reason) {
|
|
567
|
+
rejected.push({ title: l.title, reason });
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const res = await this.upsertMemory(l);
|
|
571
|
+
saved.push({ id: res.id, kind: l.kind, title: l.title, deduped: res.deduped });
|
|
572
|
+
const polarity = l.polarity ?? "positive";
|
|
573
|
+
if (polarity === "positive") {
|
|
574
|
+
positiveMemoryIds.push(res.id);
|
|
575
|
+
} else {
|
|
576
|
+
negativeMemoryIds.push(res.id);
|
|
577
|
+
}
|
|
578
|
+
if (l.triage?.symptoms) {
|
|
579
|
+
allSymptoms.push(...l.triage.symptoms);
|
|
580
|
+
}
|
|
581
|
+
if (l.env && !firstEnv) {
|
|
582
|
+
firstEnv = l.env;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (allSymptoms.length > 0 && (positiveMemoryIds.length > 0 || negativeMemoryIds.length > 0)) {
|
|
586
|
+
const uniqueSymptoms = [...new Set(allSymptoms)];
|
|
587
|
+
const caseId = req.sessionId ? `case_${req.sessionId}` : newId("case");
|
|
588
|
+
await this.upsertCase({
|
|
589
|
+
id: caseId,
|
|
590
|
+
title: req.learnings[0]?.title ?? "Auto-generated case",
|
|
591
|
+
summary: `Case auto-created from ${saved.length} learnings`,
|
|
592
|
+
outcome: "resolved",
|
|
593
|
+
symptoms: uniqueSymptoms,
|
|
594
|
+
env: firstEnv ?? {},
|
|
595
|
+
resolvedByMemoryIds: positiveMemoryIds,
|
|
596
|
+
negativeMemoryIds,
|
|
597
|
+
resolvedAtIso: (/* @__PURE__ */ new Date()).toISOString()
|
|
598
|
+
});
|
|
599
|
+
console.log(`\u2705 Auto-created Case ${caseId} linking ${positiveMemoryIds.length} positive and ${negativeMemoryIds.length} negative memories to symptoms: [${uniqueSymptoms.join(", ")}]`);
|
|
600
|
+
}
|
|
601
|
+
this.emit({
|
|
602
|
+
type: "write",
|
|
603
|
+
action: "saveLearnings",
|
|
604
|
+
meta: { savedCount: saved.length, rejectedCount: rejected.length }
|
|
605
|
+
});
|
|
606
|
+
return { saved, rejected };
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
async function createMemoryService(cfg) {
|
|
610
|
+
const svc = new MemoryService(cfg);
|
|
611
|
+
await svc.init();
|
|
612
|
+
return svc;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/tools.ts
|
|
616
|
+
import { z } from "zod";
|
|
617
|
+
var storeInputSchema = z.object({
|
|
618
|
+
agentId: z.string(),
|
|
619
|
+
title: z.string().min(4),
|
|
620
|
+
content: z.string().min(20),
|
|
621
|
+
tags: z.array(z.string()).min(1),
|
|
622
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
623
|
+
env: z.record(z.string(), z.any()).optional(),
|
|
624
|
+
triage: z.object({
|
|
625
|
+
symptoms: z.array(z.string()).min(1),
|
|
626
|
+
likelyCauses: z.array(z.string()).min(1),
|
|
627
|
+
verificationSteps: z.array(z.string()).optional(),
|
|
628
|
+
fixSteps: z.array(z.string()).optional(),
|
|
629
|
+
gotchas: z.array(z.string()).optional()
|
|
630
|
+
}).optional(),
|
|
631
|
+
antiPattern: z.object({
|
|
632
|
+
action: z.string(),
|
|
633
|
+
whyBad: z.string(),
|
|
634
|
+
saferAlternative: z.string().optional()
|
|
635
|
+
}).optional()
|
|
636
|
+
});
|
|
637
|
+
var recallInputSchema = z.object({
|
|
638
|
+
agentId: z.string().optional(),
|
|
639
|
+
limit: z.number().int().min(1).max(100).optional()
|
|
640
|
+
});
|
|
641
|
+
var relateInputSchema = z.object({
|
|
642
|
+
sourceId: z.string(),
|
|
643
|
+
targetId: z.string(),
|
|
644
|
+
weight: z.number().min(0).max(1).optional()
|
|
645
|
+
});
|
|
646
|
+
function toLearning(kind, input, extraTags = []) {
|
|
647
|
+
return {
|
|
648
|
+
kind,
|
|
649
|
+
title: input.title,
|
|
650
|
+
content: input.content,
|
|
651
|
+
tags: [.../* @__PURE__ */ new Set([...input.tags ?? [], ...extraTags])],
|
|
652
|
+
confidence: input.confidence ?? 0.7,
|
|
653
|
+
env: input.env,
|
|
654
|
+
triage: input.triage,
|
|
655
|
+
antiPattern: input.antiPattern
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function createMemoryTools(service) {
|
|
659
|
+
const storeBase = (kind, extraTags = []) => ({
|
|
660
|
+
name: "store_concept",
|
|
661
|
+
description: "Store a memory item.",
|
|
662
|
+
inputSchema: storeInputSchema,
|
|
663
|
+
execute: async (input) => {
|
|
664
|
+
const learning = toLearning(kind, input, extraTags);
|
|
665
|
+
return service.saveLearnings({ agentId: input.agentId, learnings: [learning] });
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
const recallBase = async (kind, input) => {
|
|
669
|
+
if (kind === "skills") return service.listSkills({ agentId: input.agentId, limit: input.limit });
|
|
670
|
+
if (kind === "concepts") return service.listConcepts({ agentId: input.agentId, limit: input.limit });
|
|
671
|
+
const memories = await service.listConcepts({ agentId: input.agentId, limit: input.limit });
|
|
672
|
+
return memories.filter((m) => m.tags.includes("pattern"));
|
|
673
|
+
};
|
|
674
|
+
return {
|
|
675
|
+
store_skill: {
|
|
676
|
+
...storeBase("procedural"),
|
|
677
|
+
name: "store_skill",
|
|
678
|
+
description: "Store a procedural memory (skill)."
|
|
679
|
+
},
|
|
680
|
+
store_pattern: {
|
|
681
|
+
...storeBase("semantic", ["pattern"]),
|
|
682
|
+
name: "store_pattern",
|
|
683
|
+
description: "Store a semantic memory tagged as a pattern."
|
|
684
|
+
},
|
|
685
|
+
store_concept: {
|
|
686
|
+
...storeBase("semantic"),
|
|
687
|
+
name: "store_concept",
|
|
688
|
+
description: "Store a semantic memory (concept)."
|
|
689
|
+
},
|
|
690
|
+
relate_concepts: {
|
|
691
|
+
name: "relate_concepts",
|
|
692
|
+
description: "Relate two concept memories with a weighted edge.",
|
|
693
|
+
inputSchema: relateInputSchema,
|
|
694
|
+
execute: async (input) => {
|
|
695
|
+
await service.relateConcepts({
|
|
696
|
+
sourceId: input.sourceId,
|
|
697
|
+
targetId: input.targetId,
|
|
698
|
+
weight: input.weight
|
|
699
|
+
});
|
|
700
|
+
return { ok: true };
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
recall_skills: {
|
|
704
|
+
name: "recall_skills",
|
|
705
|
+
description: "List stored skills (procedural memories).",
|
|
706
|
+
inputSchema: recallInputSchema,
|
|
707
|
+
execute: (input) => recallBase("skills", input)
|
|
708
|
+
},
|
|
709
|
+
recall_concepts: {
|
|
710
|
+
name: "recall_concepts",
|
|
711
|
+
description: "List stored concepts (semantic memories).",
|
|
712
|
+
inputSchema: recallInputSchema,
|
|
713
|
+
execute: (input) => recallBase("concepts", input)
|
|
714
|
+
},
|
|
715
|
+
recall_patterns: {
|
|
716
|
+
name: "recall_patterns",
|
|
717
|
+
description: "List stored patterns (semantic memories tagged 'pattern').",
|
|
718
|
+
inputSchema: recallInputSchema,
|
|
719
|
+
execute: (input) => recallBase("patterns", input)
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
export {
|
|
724
|
+
MemoryService,
|
|
725
|
+
Neo4jClient,
|
|
726
|
+
canonicaliseForHash,
|
|
727
|
+
createMemoryService,
|
|
728
|
+
createMemoryTools,
|
|
729
|
+
cypher,
|
|
730
|
+
ensureSchema,
|
|
731
|
+
envHash,
|
|
732
|
+
loadCypher,
|
|
733
|
+
migrate,
|
|
734
|
+
newId,
|
|
735
|
+
normaliseSymptom,
|
|
736
|
+
schemaVersion,
|
|
737
|
+
sha256Hex
|
|
738
|
+
};
|