bikky 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +323 -0
- package/bin/bikky.js +6 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +51 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +141 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +8 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +449 -0
- package/dist/config.test.js.map +1 -0
- package/dist/daemon/consolidation.d.ts +60 -0
- package/dist/daemon/consolidation.d.ts.map +1 -0
- package/dist/daemon/consolidation.js +448 -0
- package/dist/daemon/consolidation.js.map +1 -0
- package/dist/daemon/extraction.d.ts +17 -0
- package/dist/daemon/extraction.d.ts.map +1 -0
- package/dist/daemon/extraction.js +465 -0
- package/dist/daemon/extraction.js.map +1 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.d.ts.map +1 -0
- package/dist/daemon/index.js +3 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/daemon/loop.d.ts +3 -0
- package/dist/daemon/loop.d.ts.map +1 -0
- package/dist/daemon/loop.js +93 -0
- package/dist/daemon/loop.js.map +1 -0
- package/dist/daemon/qdrant.d.ts +103 -0
- package/dist/daemon/qdrant.d.ts.map +1 -0
- package/dist/daemon/qdrant.js +273 -0
- package/dist/daemon/qdrant.js.map +1 -0
- package/dist/daemon/qdrant.test.d.ts +8 -0
- package/dist/daemon/qdrant.test.d.ts.map +1 -0
- package/dist/daemon/qdrant.test.js +209 -0
- package/dist/daemon/qdrant.test.js.map +1 -0
- package/dist/daemon/relations.d.ts +54 -0
- package/dist/daemon/relations.d.ts.map +1 -0
- package/dist/daemon/relations.js +290 -0
- package/dist/daemon/relations.js.map +1 -0
- package/dist/daemon/staleness.d.ts +24 -0
- package/dist/daemon/staleness.d.ts.map +1 -0
- package/dist/daemon/staleness.js +63 -0
- package/dist/daemon/staleness.js.map +1 -0
- package/dist/daemon/watcher.d.ts +11 -0
- package/dist/daemon/watcher.d.ts.map +1 -0
- package/dist/daemon/watcher.js +38 -0
- package/dist/daemon/watcher.js.map +1 -0
- package/dist/daemon/watcher.test.d.ts +8 -0
- package/dist/daemon/watcher.test.d.ts.map +1 -0
- package/dist/daemon/watcher.test.js +214 -0
- package/dist/daemon/watcher.test.js.map +1 -0
- package/dist/install.d.ts +5 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +49 -0
- package/dist/install.js.map +1 -0
- package/dist/install.test.d.ts +9 -0
- package/dist/install.test.d.ts.map +1 -0
- package/dist/install.test.js +126 -0
- package/dist/install.test.js.map +1 -0
- package/dist/llm/embedding.d.ts +13 -0
- package/dist/llm/embedding.d.ts.map +1 -0
- package/dist/llm/embedding.js +127 -0
- package/dist/llm/embedding.js.map +1 -0
- package/dist/llm/embedding.test.d.ts +8 -0
- package/dist/llm/embedding.test.d.ts.map +1 -0
- package/dist/llm/embedding.test.js +117 -0
- package/dist/llm/embedding.test.js.map +1 -0
- package/dist/llm/index.d.ts +4 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +3 -0
- package/dist/llm/index.js.map +1 -0
- package/dist/llm/inference.d.ts +12 -0
- package/dist/llm/inference.d.ts.map +1 -0
- package/dist/llm/inference.js +146 -0
- package/dist/llm/inference.js.map +1 -0
- package/dist/llm/inference.test.d.ts +8 -0
- package/dist/llm/inference.test.d.ts.map +1 -0
- package/dist/llm/inference.test.js +117 -0
- package/dist/llm/inference.test.js.map +1 -0
- package/dist/llm/types.d.ts +41 -0
- package/dist/llm/types.d.ts.map +1 -0
- package/dist/llm/types.js +5 -0
- package/dist/llm/types.js.map +1 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/api.d.ts +30 -0
- package/dist/mcp/api.d.ts.map +1 -0
- package/dist/mcp/api.js +150 -0
- package/dist/mcp/api.js.map +1 -0
- package/dist/mcp/helpers.d.ts +35 -0
- package/dist/mcp/helpers.d.ts.map +1 -0
- package/dist/mcp/helpers.js +152 -0
- package/dist/mcp/helpers.js.map +1 -0
- package/dist/mcp/helpers.test.d.ts +5 -0
- package/dist/mcp/helpers.test.d.ts.map +1 -0
- package/dist/mcp/helpers.test.js +487 -0
- package/dist/mcp/helpers.test.js.map +1 -0
- package/dist/mcp/index.d.ts +9 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +67 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/taxonomy.d.ts +38 -0
- package/dist/mcp/taxonomy.d.ts.map +1 -0
- package/dist/mcp/taxonomy.js +223 -0
- package/dist/mcp/taxonomy.js.map +1 -0
- package/dist/mcp/taxonomy.test.d.ts +5 -0
- package/dist/mcp/taxonomy.test.d.ts.map +1 -0
- package/dist/mcp/taxonomy.test.js +341 -0
- package/dist/mcp/taxonomy.test.js.map +1 -0
- package/dist/mcp/tools.d.ts +6 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +958 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/mcp/types.d.ts +118 -0
- package/dist/mcp/types.d.ts.map +1 -0
- package/dist/mcp/types.js +5 -0
- package/dist/mcp/types.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool definitions — all 12 memory tools.
|
|
3
|
+
*/
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { STALENESS_DAYS, THRESHOLD_DUPLICATE, THRESHOLD_RELATED, QDRANT_INDEXES, categoryValues, domainValues, kindValues, sourceValues, DEFAULT_DOMAIN, DEFAULT_KIND, DEFAULT_SOURCE, } from "./taxonomy.js";
|
|
7
|
+
import { contentHash, daysSince, lastActivityDate, computeCombinedScore, buildFilter, formatFact, } from "./helpers.js";
|
|
8
|
+
import { ready, qdrantUrl, qdrantApiKey, setQdrantUrl, setQdrantApiKey, setReady, getCollection, log, embed, getEmbeddingConfig, chatComplete, qdrantReq, ensureCollection, qdrantUpsert, qdrantSearch, qdrantScroll, qdrantSetPayload, qdrantGetPoints, } from "./api.js";
|
|
9
|
+
import { saveConfig, loadConfig } from "../config.js";
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Runtime state
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const NUDGE_INTERVAL_MS = 10 * 60 * 1000;
|
|
14
|
+
let lastStoreTime = Date.now();
|
|
15
|
+
let heartbeatCount = 0;
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
function nowISO() {
|
|
20
|
+
return new Date().toISOString();
|
|
21
|
+
}
|
|
22
|
+
function newId() {
|
|
23
|
+
return crypto.randomUUID();
|
|
24
|
+
}
|
|
25
|
+
function requireReady() {
|
|
26
|
+
if (!ready) {
|
|
27
|
+
const missing = [];
|
|
28
|
+
if (!qdrantUrl)
|
|
29
|
+
missing.push("qdrant-url");
|
|
30
|
+
if (!qdrantApiKey)
|
|
31
|
+
missing.push("qdrant-api-key");
|
|
32
|
+
return {
|
|
33
|
+
content: [{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: JSON.stringify({
|
|
36
|
+
status: "setup_required",
|
|
37
|
+
ready: false,
|
|
38
|
+
missing,
|
|
39
|
+
setup_instructions: "Memory is not configured. Run `bikky setup` or call configure_credentials:\n" +
|
|
40
|
+
"1. Go to cloud.qdrant.io → sign up (free tier: 1GB, no credit card)\n" +
|
|
41
|
+
"2. Create a cluster → copy the REST URL and API key\n" +
|
|
42
|
+
"3. Call configure_credentials with Qdrant values",
|
|
43
|
+
next_step: "Call get_setup_status for detailed status, or configure_credentials to set up.",
|
|
44
|
+
}, null, 2),
|
|
45
|
+
}],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function buildMemoryNudge() {
|
|
51
|
+
const elapsed = Date.now() - lastStoreTime;
|
|
52
|
+
if (elapsed < NUDGE_INTERVAL_MS)
|
|
53
|
+
return null;
|
|
54
|
+
const mins = Math.round(elapsed / 60000);
|
|
55
|
+
return `🧠 Memory nudge: No memory_store calls in ${mins} minutes. ` +
|
|
56
|
+
"If you've learned project facts, made key decisions, discovered service quirks, " +
|
|
57
|
+
"or resolved errors — store them now so future sessions benefit.";
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Entity-graph traversal for memory_recall.
|
|
61
|
+
*/
|
|
62
|
+
async function graphTraversal(primaryResults, limit) {
|
|
63
|
+
try {
|
|
64
|
+
const primaryEntities = new Set();
|
|
65
|
+
const primaryIds = new Set();
|
|
66
|
+
for (const r of primaryResults) {
|
|
67
|
+
primaryIds.add(r.id);
|
|
68
|
+
for (const e of (r.payload.entities ?? [])) {
|
|
69
|
+
primaryEntities.add(e.toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (primaryEntities.size === 0)
|
|
73
|
+
return [];
|
|
74
|
+
const relatedEntities = new Set();
|
|
75
|
+
for (const entity of primaryEntities) {
|
|
76
|
+
const outgoing = await qdrantScroll({
|
|
77
|
+
must: [
|
|
78
|
+
{ key: "from_entity", match: { value: entity } },
|
|
79
|
+
{ is_null: { key: "superseded_by" } },
|
|
80
|
+
],
|
|
81
|
+
}, 10).catch(() => ({ result: { points: [] } }));
|
|
82
|
+
for (const pt of (outgoing.result?.points ?? [])) {
|
|
83
|
+
if (pt.payload.to_entity)
|
|
84
|
+
relatedEntities.add(pt.payload.to_entity);
|
|
85
|
+
}
|
|
86
|
+
const incoming = await qdrantScroll({
|
|
87
|
+
must: [
|
|
88
|
+
{ key: "to_entity", match: { value: entity } },
|
|
89
|
+
{ is_null: { key: "superseded_by" } },
|
|
90
|
+
],
|
|
91
|
+
}, 10).catch(() => ({ result: { points: [] } }));
|
|
92
|
+
for (const pt of (incoming.result?.points ?? [])) {
|
|
93
|
+
if (pt.payload.from_entity)
|
|
94
|
+
relatedEntities.add(pt.payload.from_entity);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const e of primaryEntities)
|
|
98
|
+
relatedEntities.delete(e);
|
|
99
|
+
if (relatedEntities.size === 0)
|
|
100
|
+
return [];
|
|
101
|
+
const relatedFacts = [];
|
|
102
|
+
const maxPerEntity = Math.max(2, Math.floor(limit / relatedEntities.size));
|
|
103
|
+
for (const entity of relatedEntities) {
|
|
104
|
+
const result = await qdrantScroll({
|
|
105
|
+
must: [
|
|
106
|
+
{ key: "entities", match: { value: entity } },
|
|
107
|
+
{ is_null: { key: "superseded_by" } },
|
|
108
|
+
],
|
|
109
|
+
}, maxPerEntity).catch(() => ({ result: { points: [] } }));
|
|
110
|
+
for (const pt of (result.result?.points ?? [])) {
|
|
111
|
+
if (!primaryIds.has(pt.id)) {
|
|
112
|
+
relatedFacts.push(pt);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (relatedFacts.length >= limit)
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
return relatedFacts
|
|
119
|
+
.slice(0, Math.ceil(limit / 2))
|
|
120
|
+
.map((r) => formatFact(r));
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
return [`(graph traversal failed: ${e instanceof Error ? e.message : String(e)})`];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Tool registration
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
export function registerTools(mcp) {
|
|
130
|
+
// ── get_setup_status ────────────────────────────────────────────────────
|
|
131
|
+
mcp.tool("get_setup_status", "Check memory system status. Returns which credentials are configured and whether Qdrant and embeddings are reachable.", {}, async () => {
|
|
132
|
+
const status = {
|
|
133
|
+
ready,
|
|
134
|
+
qdrant_url: !!qdrantUrl,
|
|
135
|
+
qdrant_api_key: !!qdrantApiKey,
|
|
136
|
+
missing: [],
|
|
137
|
+
qdrant_connected: false,
|
|
138
|
+
embedding_connected: false,
|
|
139
|
+
embedding_provider: getEmbeddingConfig().provider,
|
|
140
|
+
embedding_model: getEmbeddingConfig().model,
|
|
141
|
+
embedding_dimensions: getEmbeddingConfig().dimensions,
|
|
142
|
+
};
|
|
143
|
+
const missing = status["missing"];
|
|
144
|
+
if (!qdrantUrl)
|
|
145
|
+
missing.push("qdrant-url");
|
|
146
|
+
if (!qdrantApiKey)
|
|
147
|
+
missing.push("qdrant-api-key");
|
|
148
|
+
if (qdrantUrl && qdrantApiKey) {
|
|
149
|
+
try {
|
|
150
|
+
await qdrantReq("GET", "/collections");
|
|
151
|
+
status["qdrant_connected"] = true;
|
|
152
|
+
}
|
|
153
|
+
catch { /* ignore */ }
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
await embed("test");
|
|
157
|
+
status["embedding_connected"] = true;
|
|
158
|
+
}
|
|
159
|
+
catch { /* ignore */ }
|
|
160
|
+
if (!status["ready"] && missing.length > 0) {
|
|
161
|
+
status["setup_instructions"] =
|
|
162
|
+
"Run `bikky setup` or guide the user:\n" +
|
|
163
|
+
"1. Go to cloud.qdrant.io → sign up (free tier: 1GB, no credit card)\n" +
|
|
164
|
+
"2. Create a cluster → copy the REST URL and API key\n" +
|
|
165
|
+
"3. Call configure_credentials with Qdrant values";
|
|
166
|
+
}
|
|
167
|
+
return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
|
|
168
|
+
});
|
|
169
|
+
// ── configure_credentials ───────────────────────────────────────────────
|
|
170
|
+
mcp.tool("configure_credentials", "Store Qdrant + embedding credentials in ~/.bikky/config.json. Tests connectivity and creates the collection if needed.", {
|
|
171
|
+
qdrant_url: z.string().optional().describe("Qdrant Cloud REST URL (e.g. https://xxx.cloud.qdrant.io:6333)"),
|
|
172
|
+
qdrant_api_key: z.string().optional().describe("Qdrant Cloud API key"),
|
|
173
|
+
openai_api_key: z.string().optional().describe("OpenAI API key (for OpenAI embedding/LLM provider)"),
|
|
174
|
+
}, async ({ qdrant_url, qdrant_api_key, openai_api_key }) => {
|
|
175
|
+
const results = {};
|
|
176
|
+
const cfg = loadConfig();
|
|
177
|
+
if (qdrant_url) {
|
|
178
|
+
const url = qdrant_url.replace(/\/+$/, "");
|
|
179
|
+
cfg.qdrant_url = url;
|
|
180
|
+
setQdrantUrl(url);
|
|
181
|
+
results["qdrant_url"] = "stored ✓";
|
|
182
|
+
}
|
|
183
|
+
if (qdrant_api_key) {
|
|
184
|
+
cfg.qdrant_api_key = qdrant_api_key;
|
|
185
|
+
setQdrantApiKey(qdrant_api_key);
|
|
186
|
+
results["qdrant_api_key"] = "stored ✓";
|
|
187
|
+
}
|
|
188
|
+
if (openai_api_key) {
|
|
189
|
+
cfg.embedding.api_key = openai_api_key;
|
|
190
|
+
cfg.llm.api_key = openai_api_key;
|
|
191
|
+
results["openai_api_key"] = "stored ✓";
|
|
192
|
+
}
|
|
193
|
+
saveConfig(cfg);
|
|
194
|
+
if (qdrantUrl && qdrantApiKey) {
|
|
195
|
+
try {
|
|
196
|
+
await ensureCollection(QDRANT_INDEXES);
|
|
197
|
+
results["qdrant_collection"] = `'${getCollection()}' ready ✓`;
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
results["qdrant_collection"] = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
await embed("memory system test");
|
|
205
|
+
const embCfg = getEmbeddingConfig();
|
|
206
|
+
results["embedding"] = `${embCfg.provider}/${embCfg.model} (${embCfg.dimensions}d) working ✓`;
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
results["embedding"] = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
210
|
+
}
|
|
211
|
+
setReady(!!(qdrantUrl && qdrantApiKey));
|
|
212
|
+
results["ready"] = ready;
|
|
213
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
214
|
+
});
|
|
215
|
+
// ── verify_connection ───────────────────────────────────────────────────
|
|
216
|
+
mcp.tool("verify_connection", "Test that Qdrant is reachable, embeddings work, and the collection exists.", {}, async () => {
|
|
217
|
+
const results = { qdrant: false, embedding: false, collection: false };
|
|
218
|
+
if (qdrantUrl && qdrantApiKey) {
|
|
219
|
+
try {
|
|
220
|
+
await qdrantReq("GET", "/collections");
|
|
221
|
+
results["qdrant"] = true;
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
results["qdrant_error"] = e instanceof Error ? e.message : String(e);
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
await qdrantReq("GET", `/collections/${getCollection()}`);
|
|
228
|
+
results["collection"] = true;
|
|
229
|
+
}
|
|
230
|
+
catch { /* ignore */ }
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await embed("connection test");
|
|
234
|
+
results["embedding"] = true;
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
results["embedding_error"] = e instanceof Error ? e.message : String(e);
|
|
238
|
+
}
|
|
239
|
+
const allReady = results["qdrant"] === true && results["embedding"] === true && results["collection"] === true;
|
|
240
|
+
results["ready"] = allReady;
|
|
241
|
+
setReady(allReady);
|
|
242
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
243
|
+
});
|
|
244
|
+
// ── memory_store ────────────────────────────────────────────────────────
|
|
245
|
+
mcp.tool("memory_store", "Store a new fact in memory. Automatically deduplicates via content hash and vector similarity. " +
|
|
246
|
+
"Returns the action taken (inserted/reinforced/duplicate) and any similar facts found.", {
|
|
247
|
+
content: z.string().describe("The fact to store (atomic, single piece of knowledge)"),
|
|
248
|
+
category: z.enum(categoryValues())
|
|
249
|
+
.describe("Topic: infrastructure, decisions, observation, preferences, projects, team"),
|
|
250
|
+
entities: z.array(z.string()).describe("Related entities (lowercase, e.g. ['qdrant', 'platform'])"),
|
|
251
|
+
domain: z.enum(domainValues()).default(DEFAULT_DOMAIN)
|
|
252
|
+
.describe("Life scope — work or personal"),
|
|
253
|
+
kind: z.enum(kindValues()).default(DEFAULT_KIND)
|
|
254
|
+
.describe("Knowledge form — fact, summary, distilled, relation"),
|
|
255
|
+
source: z.enum(sourceValues()).default(DEFAULT_SOURCE)
|
|
256
|
+
.describe("Creator — agent, daemon, system, user"),
|
|
257
|
+
confidence: z.number().min(0).max(1).default(0.9).describe("How certain (0.0-1.0)"),
|
|
258
|
+
importance: z.number().min(0).max(1).optional().describe("How important (0.0-1.0). Omit to default to 0.5."),
|
|
259
|
+
supersedes: z.string().optional().describe("ID of a fact this one replaces"),
|
|
260
|
+
relation: z.object({
|
|
261
|
+
from: z.string().describe("Source entity"),
|
|
262
|
+
type: z.string().describe("Relation type (owns, uses, decided, prefers, works-on, etc.)"),
|
|
263
|
+
to: z.string().describe("Target entity"),
|
|
264
|
+
}).optional().describe("Optional typed relation between two entities"),
|
|
265
|
+
metadata: z.record(z.string(), z.string()).optional()
|
|
266
|
+
.describe("Optional key-value metadata. Stored with the fact and filterable via memory_recall."),
|
|
267
|
+
}, async ({ content, category, entities, domain, kind, source, confidence, importance, supersedes, relation, metadata }) => {
|
|
268
|
+
const guard = requireReady();
|
|
269
|
+
if (guard)
|
|
270
|
+
return guard;
|
|
271
|
+
lastStoreTime = Date.now();
|
|
272
|
+
const now = nowISO();
|
|
273
|
+
const hash = contentHash(category, content);
|
|
274
|
+
const normalizedEntities = entities.map((e) => e.toLowerCase());
|
|
275
|
+
// 1. Exact dedup via content hash
|
|
276
|
+
try {
|
|
277
|
+
const existing = await qdrantScroll({ must: [
|
|
278
|
+
{ key: "content_hash", match: { value: hash } },
|
|
279
|
+
{ is_null: { key: "superseded_by" } },
|
|
280
|
+
] }, 1);
|
|
281
|
+
const existingPoint = existing.result?.points?.[0];
|
|
282
|
+
if (existingPoint) {
|
|
283
|
+
const point = existingPoint;
|
|
284
|
+
const count = (point.payload.reinforcement_count || 1) + 1;
|
|
285
|
+
await qdrantSetPayload([point.id], {
|
|
286
|
+
reinforcement_count: count,
|
|
287
|
+
last_reinforced_at: now,
|
|
288
|
+
updated_at: now,
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
292
|
+
action: "reinforced",
|
|
293
|
+
fact_id: point.id,
|
|
294
|
+
reinforcement_count: count,
|
|
295
|
+
message: "Exact match found — reinforced existing fact.",
|
|
296
|
+
}) }],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
log("WARN", `Hash dedup check failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
302
|
+
}
|
|
303
|
+
// 2. Generate embedding
|
|
304
|
+
const vector = await embed(content);
|
|
305
|
+
// 3. Semantic dedup
|
|
306
|
+
let similarFacts = [];
|
|
307
|
+
let potentialConflicts = [];
|
|
308
|
+
try {
|
|
309
|
+
const filter = { must: [] };
|
|
310
|
+
if (normalizedEntities.length > 0) {
|
|
311
|
+
filter.must.push({ key: "entities", match: { any: normalizedEntities } });
|
|
312
|
+
}
|
|
313
|
+
filter.must.push({ is_null: { key: "superseded_by" } });
|
|
314
|
+
const results = await qdrantSearch(vector, filter.must.length > 0 ? filter : undefined, 3);
|
|
315
|
+
const firstResult = results.result?.[0];
|
|
316
|
+
if (results.result?.length > 0 && firstResult) {
|
|
317
|
+
const topScore = firstResult.score ?? 0;
|
|
318
|
+
if (topScore > THRESHOLD_DUPLICATE) {
|
|
319
|
+
const point = firstResult;
|
|
320
|
+
const count = (point.payload.reinforcement_count || 1) + 1;
|
|
321
|
+
await qdrantSetPayload([point.id], {
|
|
322
|
+
reinforcement_count: count,
|
|
323
|
+
last_reinforced_at: now,
|
|
324
|
+
updated_at: now,
|
|
325
|
+
});
|
|
326
|
+
return {
|
|
327
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
328
|
+
action: "reinforced",
|
|
329
|
+
fact_id: point.id,
|
|
330
|
+
reinforcement_count: count,
|
|
331
|
+
similarity: topScore,
|
|
332
|
+
message: "Near-duplicate found (>0.92 similarity) — reinforced existing fact.",
|
|
333
|
+
}) }],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (topScore > THRESHOLD_RELATED) {
|
|
337
|
+
similarFacts = results.result
|
|
338
|
+
.filter((r) => (r.score ?? 0) > THRESHOLD_RELATED)
|
|
339
|
+
.map((r) => ({
|
|
340
|
+
id: r.id,
|
|
341
|
+
content: r.payload.content,
|
|
342
|
+
score: r.score ?? 0,
|
|
343
|
+
}));
|
|
344
|
+
const sharedEntityFacts = results.result.filter((r) => {
|
|
345
|
+
const s = r.score ?? 0;
|
|
346
|
+
if (s <= THRESHOLD_RELATED || s > THRESHOLD_DUPLICATE)
|
|
347
|
+
return false;
|
|
348
|
+
const existingEntities = r.payload.entities ?? [];
|
|
349
|
+
return normalizedEntities.some((e) => existingEntities.includes(e));
|
|
350
|
+
});
|
|
351
|
+
if (sharedEntityFacts.length > 0) {
|
|
352
|
+
potentialConflicts = sharedEntityFacts.map((r) => ({
|
|
353
|
+
id: r.id,
|
|
354
|
+
content: r.payload.content,
|
|
355
|
+
category: r.payload.category,
|
|
356
|
+
similarity: r.score ?? 0,
|
|
357
|
+
shared_entities: normalizedEntities.filter((e) => (r.payload.entities ?? []).includes(e)),
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
log("WARN", `Semantic dedup search failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
365
|
+
}
|
|
366
|
+
// 4. Generate fact ID
|
|
367
|
+
const factId = newId();
|
|
368
|
+
// 5. Supersede old fact if requested
|
|
369
|
+
if (supersedes) {
|
|
370
|
+
try {
|
|
371
|
+
await qdrantSetPayload([supersedes], {
|
|
372
|
+
superseded_by: factId,
|
|
373
|
+
superseded_at: now,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
log("WARN", `Failed to supersede ${supersedes}: ${e instanceof Error ? e.message : String(e)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// 6. Insert new fact
|
|
381
|
+
const payload = {
|
|
382
|
+
content,
|
|
383
|
+
category,
|
|
384
|
+
domain,
|
|
385
|
+
kind,
|
|
386
|
+
entities: normalizedEntities,
|
|
387
|
+
source,
|
|
388
|
+
confidence,
|
|
389
|
+
importance: importance ?? 0.5,
|
|
390
|
+
content_hash: hash,
|
|
391
|
+
reinforcement_count: 1,
|
|
392
|
+
last_reinforced_at: now,
|
|
393
|
+
superseded_by: null,
|
|
394
|
+
superseded_at: null,
|
|
395
|
+
created_at: now,
|
|
396
|
+
updated_at: now,
|
|
397
|
+
};
|
|
398
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
399
|
+
payload["metadata"] = metadata;
|
|
400
|
+
}
|
|
401
|
+
await qdrantUpsert(factId, vector, payload);
|
|
402
|
+
// 7. Insert relation point if provided
|
|
403
|
+
let relationId = null;
|
|
404
|
+
if (relation) {
|
|
405
|
+
relationId = newId();
|
|
406
|
+
const relContent = `${relation.from} ${relation.type} ${relation.to}`;
|
|
407
|
+
const relVector = await embed(relContent);
|
|
408
|
+
const relPayload = {
|
|
409
|
+
content: relContent,
|
|
410
|
+
category,
|
|
411
|
+
domain,
|
|
412
|
+
kind: "relation",
|
|
413
|
+
entities: [relation.from.toLowerCase(), relation.to.toLowerCase()],
|
|
414
|
+
source,
|
|
415
|
+
confidence,
|
|
416
|
+
content_hash: contentHash("relation", relContent),
|
|
417
|
+
reinforcement_count: 1,
|
|
418
|
+
last_reinforced_at: now,
|
|
419
|
+
superseded_by: null,
|
|
420
|
+
superseded_at: null,
|
|
421
|
+
created_at: now,
|
|
422
|
+
updated_at: now,
|
|
423
|
+
from_entity: relation.from.toLowerCase(),
|
|
424
|
+
relation_type: relation.type.toLowerCase(),
|
|
425
|
+
to_entity: relation.to.toLowerCase(),
|
|
426
|
+
};
|
|
427
|
+
await qdrantUpsert(relationId, relVector, relPayload);
|
|
428
|
+
}
|
|
429
|
+
const result = {
|
|
430
|
+
action: "inserted",
|
|
431
|
+
fact_id: factId,
|
|
432
|
+
};
|
|
433
|
+
if (relationId)
|
|
434
|
+
result["relation_id"] = relationId;
|
|
435
|
+
if (similarFacts.length > 0)
|
|
436
|
+
result["similar_facts"] = similarFacts;
|
|
437
|
+
if (potentialConflicts.length > 0) {
|
|
438
|
+
result["potential_conflicts"] = potentialConflicts;
|
|
439
|
+
result["conflict_hint"] =
|
|
440
|
+
"These existing facts cover similar topics with shared entities but different content. " +
|
|
441
|
+
"Consider using `supersedes` to replace outdated ones, or `memory_forget` to retire them.";
|
|
442
|
+
}
|
|
443
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
444
|
+
});
|
|
445
|
+
// ── memory_recall ───────────────────────────────────────────────────────
|
|
446
|
+
mcp.tool("memory_recall", "Semantic search over memory. Returns facts ranked by relevance. " +
|
|
447
|
+
"Use on session start with a broad query for context briefing.", {
|
|
448
|
+
query: z.string().describe("What to search for (natural language)"),
|
|
449
|
+
category: z.string().optional().describe("Filter by category"),
|
|
450
|
+
domain: z.string().optional().describe("Filter by domain (work or personal)"),
|
|
451
|
+
kind: z.string().optional().describe("Filter by kind (fact, summary, distilled, relation)"),
|
|
452
|
+
entity: z.string().optional().describe("Filter by entity name"),
|
|
453
|
+
since: z.string().optional().describe("Only facts created after this ISO date"),
|
|
454
|
+
until: z.string().optional().describe("Only facts created before this ISO date"),
|
|
455
|
+
limit: z.number().optional().default(10).describe("Max results (default 10)"),
|
|
456
|
+
graph_depth: z.number().optional().default(0).describe("Entity graph traversal depth (0=none, 1=include 1-hop related entity facts)."),
|
|
457
|
+
metadata_filter: z.record(z.string(), z.string()).optional()
|
|
458
|
+
.describe("Filter by metadata key-value pairs. All pairs must match."),
|
|
459
|
+
}, async ({ query, category, domain, kind, entity, since, until, limit, graph_depth, metadata_filter }) => {
|
|
460
|
+
const guard = requireReady();
|
|
461
|
+
if (guard)
|
|
462
|
+
return guard;
|
|
463
|
+
const requestedLimit = limit ?? 10;
|
|
464
|
+
const vector = await embed(query);
|
|
465
|
+
const filter = buildFilter({ category, domain, kind, entity, since, until, metadata: metadata_filter });
|
|
466
|
+
const results = await qdrantSearch(vector, filter, requestedLimit * 2);
|
|
467
|
+
if (!results.result?.length) {
|
|
468
|
+
const nudge = buildMemoryNudge();
|
|
469
|
+
const text = nudge ? `No matching facts found.\n\n${nudge}` : "No matching facts found.";
|
|
470
|
+
return { content: [{ type: "text", text }] };
|
|
471
|
+
}
|
|
472
|
+
const ranked = results.result
|
|
473
|
+
.map((r) => ({ ...r, _combinedScore: computeCombinedScore(r) }))
|
|
474
|
+
.sort((a, b) => b._combinedScore - a._combinedScore)
|
|
475
|
+
.slice(0, requestedLimit);
|
|
476
|
+
const lines = ranked.map((r) => formatFact(r));
|
|
477
|
+
if ((graph_depth ?? 0) >= 1) {
|
|
478
|
+
const relatedLines = await graphTraversal(ranked, requestedLimit);
|
|
479
|
+
if (relatedLines.length > 0) {
|
|
480
|
+
lines.push("", "── Related (1-hop) ──");
|
|
481
|
+
lines.push(...relatedLines);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const nudge = buildMemoryNudge();
|
|
485
|
+
if (nudge)
|
|
486
|
+
lines.push("", nudge);
|
|
487
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
488
|
+
});
|
|
489
|
+
// ── memory_entity ───────────────────────────────────────────────────────
|
|
490
|
+
mcp.tool("memory_entity", "Get everything known about an entity — all facts mentioning it plus its relationships.", {
|
|
491
|
+
name: z.string().describe("Entity name (e.g. 'qdrant', 'platform')"),
|
|
492
|
+
limit: z.number().optional().default(20).describe("Max facts to return"),
|
|
493
|
+
}, async ({ name, limit }) => {
|
|
494
|
+
const guard = requireReady();
|
|
495
|
+
if (guard)
|
|
496
|
+
return guard;
|
|
497
|
+
const entityName = name.toLowerCase();
|
|
498
|
+
const facts = await qdrantScroll({
|
|
499
|
+
must: [
|
|
500
|
+
{ key: "entities", match: { value: entityName } },
|
|
501
|
+
{ is_null: { key: "superseded_by" } },
|
|
502
|
+
],
|
|
503
|
+
}, limit ?? 20);
|
|
504
|
+
const relationsFrom = await qdrantScroll({ must: [
|
|
505
|
+
{ key: "from_entity", match: { value: entityName } },
|
|
506
|
+
{ is_null: { key: "superseded_by" } },
|
|
507
|
+
] }, 50);
|
|
508
|
+
const relationsTo = await qdrantScroll({ must: [
|
|
509
|
+
{ key: "to_entity", match: { value: entityName } },
|
|
510
|
+
{ is_null: { key: "superseded_by" } },
|
|
511
|
+
] }, 50);
|
|
512
|
+
const output = [];
|
|
513
|
+
const factPoints = facts.result?.points ?? [];
|
|
514
|
+
if (factPoints.length > 0) {
|
|
515
|
+
output.push(`## Facts about ${name} (${factPoints.length})`);
|
|
516
|
+
for (const p of factPoints) {
|
|
517
|
+
if (p.payload.category !== "relation") {
|
|
518
|
+
output.push(`- ${formatFact(p)}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const allRelations = [
|
|
523
|
+
...(relationsFrom.result?.points ?? []),
|
|
524
|
+
...(relationsTo.result?.points ?? []),
|
|
525
|
+
];
|
|
526
|
+
const seen = new Set();
|
|
527
|
+
const uniqueRelations = allRelations.filter((r) => {
|
|
528
|
+
if (seen.has(r.id))
|
|
529
|
+
return false;
|
|
530
|
+
seen.add(r.id);
|
|
531
|
+
return true;
|
|
532
|
+
});
|
|
533
|
+
if (uniqueRelations.length > 0) {
|
|
534
|
+
output.push(`\n## Relations (${uniqueRelations.length})`);
|
|
535
|
+
for (const r of uniqueRelations) {
|
|
536
|
+
const p = r.payload;
|
|
537
|
+
output.push(`- ${p.from_entity} --[${p.relation_type}]--> ${p.to_entity}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (output.length === 0) {
|
|
541
|
+
return { content: [{ type: "text", text: `No facts or relations found for '${name}'.` }] };
|
|
542
|
+
}
|
|
543
|
+
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
544
|
+
});
|
|
545
|
+
// ── memory_relations ────────────────────────────────────────────────────
|
|
546
|
+
mcp.tool("memory_relations", "Query entity relationships. Returns typed edges between entities.", {
|
|
547
|
+
entity: z.string().describe("Entity name to query"),
|
|
548
|
+
relation_type: z.string().optional().describe("Filter by relation type (e.g. 'owns', 'uses', 'decided')"),
|
|
549
|
+
direction: z.enum(["from", "to", "both"]).optional().default("both")
|
|
550
|
+
.describe("Direction: 'from' (entity as source), 'to' (entity as target), 'both'"),
|
|
551
|
+
}, async ({ entity, relation_type, direction }) => {
|
|
552
|
+
const guard = requireReady();
|
|
553
|
+
if (guard)
|
|
554
|
+
return guard;
|
|
555
|
+
const entityName = entity.toLowerCase();
|
|
556
|
+
const results = [];
|
|
557
|
+
if (direction === "from" || direction === "both") {
|
|
558
|
+
const filter = { must: [
|
|
559
|
+
{ key: "from_entity", match: { value: entityName } },
|
|
560
|
+
{ is_null: { key: "superseded_by" } },
|
|
561
|
+
] };
|
|
562
|
+
if (relation_type) {
|
|
563
|
+
filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
|
|
564
|
+
}
|
|
565
|
+
const r = await qdrantScroll(filter, 50);
|
|
566
|
+
results.push(...(r.result?.points ?? []));
|
|
567
|
+
}
|
|
568
|
+
if (direction === "to" || direction === "both") {
|
|
569
|
+
const filter = { must: [
|
|
570
|
+
{ key: "to_entity", match: { value: entityName } },
|
|
571
|
+
{ is_null: { key: "superseded_by" } },
|
|
572
|
+
] };
|
|
573
|
+
if (relation_type) {
|
|
574
|
+
filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
|
|
575
|
+
}
|
|
576
|
+
const r = await qdrantScroll(filter, 50);
|
|
577
|
+
results.push(...(r.result?.points ?? []));
|
|
578
|
+
}
|
|
579
|
+
const seen = new Set();
|
|
580
|
+
const unique = results.filter((r) => {
|
|
581
|
+
if (seen.has(r.id))
|
|
582
|
+
return false;
|
|
583
|
+
seen.add(r.id);
|
|
584
|
+
return true;
|
|
585
|
+
});
|
|
586
|
+
if (unique.length === 0) {
|
|
587
|
+
return { content: [{ type: "text", text: `No relations found for '${entity}'.` }] };
|
|
588
|
+
}
|
|
589
|
+
const lines = unique.map((r) => {
|
|
590
|
+
const p = r.payload;
|
|
591
|
+
return `${p.from_entity} --[${p.relation_type}]--> ${p.to_entity} (confidence: ${p.confidence}, id: ${r.id})`;
|
|
592
|
+
});
|
|
593
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
594
|
+
});
|
|
595
|
+
// ── memory_forget ───────────────────────────────────────────────────────
|
|
596
|
+
mcp.tool("memory_forget", "Mark a fact as superseded/wrong. The fact remains but is excluded from recall results.", {
|
|
597
|
+
fact_id: z.string().describe("ID of the fact to forget"),
|
|
598
|
+
reason: z.string().describe("Why this fact is being superseded"),
|
|
599
|
+
}, async ({ fact_id, reason }) => {
|
|
600
|
+
const guard = requireReady();
|
|
601
|
+
if (guard)
|
|
602
|
+
return guard;
|
|
603
|
+
const now = nowISO();
|
|
604
|
+
try {
|
|
605
|
+
await qdrantSetPayload([fact_id], {
|
|
606
|
+
superseded_by: `forgotten:${reason}`,
|
|
607
|
+
superseded_at: now,
|
|
608
|
+
updated_at: now,
|
|
609
|
+
});
|
|
610
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "forgotten", fact_id, reason }) }] };
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
// ── memory_verify ───────────────────────────────────────────────────────
|
|
617
|
+
mcp.tool("memory_verify", "Confirm a fact is still accurate. Resets the staleness clock and bumps verification count.", {
|
|
618
|
+
fact_id: z.string().describe("ID of the fact to verify"),
|
|
619
|
+
}, async ({ fact_id }) => {
|
|
620
|
+
const guard = requireReady();
|
|
621
|
+
if (guard)
|
|
622
|
+
return guard;
|
|
623
|
+
const now = nowISO();
|
|
624
|
+
try {
|
|
625
|
+
const existing = await qdrantGetPoints([fact_id]).catch(() => null);
|
|
626
|
+
let currentCount = 0;
|
|
627
|
+
const existingPt = existing?.result?.[0];
|
|
628
|
+
if (existingPt) {
|
|
629
|
+
currentCount = existingPt.payload.verification_count ?? 0;
|
|
630
|
+
}
|
|
631
|
+
const newCount = currentCount + 1;
|
|
632
|
+
await qdrantSetPayload([fact_id], {
|
|
633
|
+
last_verified_at: now,
|
|
634
|
+
last_reinforced_at: now,
|
|
635
|
+
verification_count: newCount,
|
|
636
|
+
updated_at: now,
|
|
637
|
+
});
|
|
638
|
+
return {
|
|
639
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
640
|
+
status: "verified",
|
|
641
|
+
fact_id,
|
|
642
|
+
verification_count: newCount,
|
|
643
|
+
message: "Fact confirmed as still accurate. Staleness clock reset.",
|
|
644
|
+
}) }],
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
// ── memory_review ───────────────────────────────────────────────────────
|
|
652
|
+
mcp.tool("memory_review", "Review recent daemon-extracted facts. Supports approve (verify), reject (forget), or correct (supersede with edited text).", {
|
|
653
|
+
limit: z.number().optional().default(10).describe("Max facts to return (default 10)"),
|
|
654
|
+
action: z.enum(["list", "approve", "reject", "correct"]).optional().default("list")
|
|
655
|
+
.describe("Action: list, approve, reject, correct"),
|
|
656
|
+
fact_id: z.string().optional().describe("Fact ID (required for approve/reject/correct)"),
|
|
657
|
+
reason: z.string().optional().describe("Reason for rejection"),
|
|
658
|
+
corrected_content: z.string().optional().describe("Corrected fact text (for correct action)"),
|
|
659
|
+
}, async ({ limit, action, fact_id, reason, corrected_content }) => {
|
|
660
|
+
const guard = requireReady();
|
|
661
|
+
if (guard)
|
|
662
|
+
return guard;
|
|
663
|
+
if (action === "list") {
|
|
664
|
+
const result = await qdrantScroll({
|
|
665
|
+
must: [
|
|
666
|
+
{ key: "source", match: { value: "daemon" } },
|
|
667
|
+
{ is_null: { key: "superseded_by" } },
|
|
668
|
+
],
|
|
669
|
+
}, (limit ?? 10) * 2);
|
|
670
|
+
const points = (result.result?.points ?? [])
|
|
671
|
+
.sort((a, b) => (b.payload.created_at ?? "").localeCompare(a.payload.created_at ?? ""))
|
|
672
|
+
.slice(0, limit ?? 10);
|
|
673
|
+
if (points.length === 0) {
|
|
674
|
+
return { content: [{ type: "text", text: "No daemon-extracted facts found." }] };
|
|
675
|
+
}
|
|
676
|
+
const lines = points.map((pt) => {
|
|
677
|
+
const p = pt.payload;
|
|
678
|
+
return `[${p.category}] ${p.content}\n id: ${pt.id} | confidence: ${p.confidence} | importance: ${p.importance} | entities: ${(p.entities ?? []).join(", ")} | created: ${p.created_at}`;
|
|
679
|
+
});
|
|
680
|
+
return { content: [{ type: "text", text: lines.join("\n\n") }] };
|
|
681
|
+
}
|
|
682
|
+
if (!fact_id) {
|
|
683
|
+
return { content: [{ type: "text", text: "Error: fact_id is required for approve/reject/correct actions." }] };
|
|
684
|
+
}
|
|
685
|
+
const now = nowISO();
|
|
686
|
+
if (action === "approve") {
|
|
687
|
+
const existing = await qdrantGetPoints([fact_id]).catch(() => null);
|
|
688
|
+
let currentCount = 0;
|
|
689
|
+
const approvePt = existing?.result?.[0];
|
|
690
|
+
if (approvePt) {
|
|
691
|
+
currentCount = approvePt.payload.verification_count ?? 0;
|
|
692
|
+
}
|
|
693
|
+
await qdrantSetPayload([fact_id], {
|
|
694
|
+
last_verified_at: now,
|
|
695
|
+
verification_count: currentCount + 1,
|
|
696
|
+
updated_at: now,
|
|
697
|
+
});
|
|
698
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "approved", fact_id }) }] };
|
|
699
|
+
}
|
|
700
|
+
if (action === "reject") {
|
|
701
|
+
if (!reason) {
|
|
702
|
+
return { content: [{ type: "text", text: "Error: reason is required for reject action." }] };
|
|
703
|
+
}
|
|
704
|
+
await qdrantSetPayload([fact_id], {
|
|
705
|
+
superseded_by: `rejected:${reason}`,
|
|
706
|
+
superseded_at: now,
|
|
707
|
+
updated_at: now,
|
|
708
|
+
});
|
|
709
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", fact_id, reason }) }] };
|
|
710
|
+
}
|
|
711
|
+
if (action === "correct") {
|
|
712
|
+
if (!corrected_content) {
|
|
713
|
+
return { content: [{ type: "text", text: "Error: corrected_content is required for correct action." }] };
|
|
714
|
+
}
|
|
715
|
+
const original = await qdrantGetPoints([fact_id]).catch(() => null);
|
|
716
|
+
const origPayload = original?.result?.[0]?.payload;
|
|
717
|
+
const vector = await embed(corrected_content);
|
|
718
|
+
const correctedId = crypto.randomUUID();
|
|
719
|
+
const origCategory = origPayload?.category ?? "observation";
|
|
720
|
+
const hash = contentHash(origCategory, corrected_content);
|
|
721
|
+
await qdrantUpsert(correctedId, vector, {
|
|
722
|
+
content: corrected_content,
|
|
723
|
+
category: origCategory,
|
|
724
|
+
domain: origPayload?.domain ?? "work",
|
|
725
|
+
kind: origPayload?.kind ?? "fact",
|
|
726
|
+
entities: origPayload?.entities ?? [],
|
|
727
|
+
source: "user",
|
|
728
|
+
confidence: 0.95,
|
|
729
|
+
importance: origPayload?.importance ?? 0.5,
|
|
730
|
+
content_hash: hash,
|
|
731
|
+
reinforcement_count: 1,
|
|
732
|
+
last_reinforced_at: now,
|
|
733
|
+
superseded_by: null,
|
|
734
|
+
superseded_at: null,
|
|
735
|
+
created_at: now,
|
|
736
|
+
updated_at: now,
|
|
737
|
+
metadata: { ...(origPayload?.metadata ?? {}), corrected_from: fact_id },
|
|
738
|
+
});
|
|
739
|
+
await qdrantSetPayload([fact_id], {
|
|
740
|
+
superseded_by: correctedId,
|
|
741
|
+
superseded_at: now,
|
|
742
|
+
updated_at: now,
|
|
743
|
+
});
|
|
744
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "corrected", old_fact_id: fact_id, new_fact_id: correctedId }) }] };
|
|
745
|
+
}
|
|
746
|
+
return { content: [{ type: "text", text: `Unknown action: ${String(action)}` }] };
|
|
747
|
+
});
|
|
748
|
+
// ── memory_session_summary ──────────────────────────────────────────────
|
|
749
|
+
mcp.tool("memory_session_summary", "Store a compressed summary of the current session. Call before a session ends or when a major task completes. " +
|
|
750
|
+
"Future sessions receive this via memory_recall('session briefing'). Idempotent per session_id.", {
|
|
751
|
+
session_id: z.string().describe("Session UUID"),
|
|
752
|
+
summary: z.string().describe("2-5 sentence compressed summary of what happened this session"),
|
|
753
|
+
tasks_completed: z.array(z.string()).optional().default([]).describe("Task slugs completed"),
|
|
754
|
+
decisions_made: z.array(z.string()).optional().default([]).describe("Key decisions"),
|
|
755
|
+
entities_touched: z.array(z.string()).optional().default([]).describe("Entities involved (lowercase)"),
|
|
756
|
+
}, async ({ session_id, summary, tasks_completed, decisions_made, entities_touched }) => {
|
|
757
|
+
const guard = requireReady();
|
|
758
|
+
if (guard)
|
|
759
|
+
return guard;
|
|
760
|
+
const now = nowISO();
|
|
761
|
+
const normalizedEntities = entities_touched.map((e) => e.toLowerCase());
|
|
762
|
+
// Check for existing summary for this session
|
|
763
|
+
try {
|
|
764
|
+
const existing = await qdrantScroll({ must: [
|
|
765
|
+
{ key: "session_id", match: { value: session_id } },
|
|
766
|
+
{ key: "kind", match: { value: "summary" } },
|
|
767
|
+
{ is_null: { key: "superseded_by" } },
|
|
768
|
+
] }, 1);
|
|
769
|
+
const summaryPoint = existing.result?.points?.[0];
|
|
770
|
+
if (summaryPoint) {
|
|
771
|
+
const point = summaryPoint;
|
|
772
|
+
const vector = await embed(summary);
|
|
773
|
+
await qdrantUpsert(point.id, vector, {
|
|
774
|
+
...point.payload,
|
|
775
|
+
content: summary,
|
|
776
|
+
kind: "summary",
|
|
777
|
+
domain: "work",
|
|
778
|
+
source: "system",
|
|
779
|
+
tasks_completed,
|
|
780
|
+
decisions_made,
|
|
781
|
+
entities: normalizedEntities,
|
|
782
|
+
content_hash: contentHash("observation", summary),
|
|
783
|
+
updated_at: now,
|
|
784
|
+
});
|
|
785
|
+
return {
|
|
786
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
787
|
+
action: "updated", fact_id: point.id, session_id,
|
|
788
|
+
}) }],
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
catch (e) {
|
|
793
|
+
log("WARN", `Session summary lookup failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
794
|
+
}
|
|
795
|
+
// Insert new summary
|
|
796
|
+
const vector = await embed(summary);
|
|
797
|
+
const factId = newId();
|
|
798
|
+
await qdrantUpsert(factId, vector, {
|
|
799
|
+
content: summary,
|
|
800
|
+
category: "observation",
|
|
801
|
+
domain: "work",
|
|
802
|
+
kind: "summary",
|
|
803
|
+
entities: normalizedEntities,
|
|
804
|
+
source: "system",
|
|
805
|
+
confidence: 1.0,
|
|
806
|
+
content_hash: contentHash("observation", summary),
|
|
807
|
+
reinforcement_count: 1,
|
|
808
|
+
last_reinforced_at: now,
|
|
809
|
+
superseded_by: null,
|
|
810
|
+
superseded_at: null,
|
|
811
|
+
created_at: now,
|
|
812
|
+
updated_at: now,
|
|
813
|
+
session_id,
|
|
814
|
+
tasks_completed,
|
|
815
|
+
decisions_made,
|
|
816
|
+
});
|
|
817
|
+
return {
|
|
818
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
819
|
+
action: "stored", fact_id: factId, session_id,
|
|
820
|
+
}) }],
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
// ── memory_distill ──────────────────────────────────────────────────────
|
|
824
|
+
mcp.tool("memory_distill", "Consolidate recent session summaries into distilled patterns. Call when 5+ session summaries exist. " +
|
|
825
|
+
"Uses LLM to extract recurring patterns and key learnings, then supersedes the source summaries.", {
|
|
826
|
+
days: z.number().optional().default(14).describe("Look-back period in days (default 14)"),
|
|
827
|
+
max_summaries: z.number().optional().default(20).describe("Max summaries to consolidate"),
|
|
828
|
+
}, async ({ days, max_summaries }) => {
|
|
829
|
+
const guard = requireReady();
|
|
830
|
+
if (guard)
|
|
831
|
+
return guard;
|
|
832
|
+
const now = nowISO();
|
|
833
|
+
const daysVal = days ?? 14;
|
|
834
|
+
const maxVal = max_summaries ?? 20;
|
|
835
|
+
const since = new Date(Date.now() - daysVal * 86400000).toISOString();
|
|
836
|
+
const summaryResults = await qdrantScroll({ must: [
|
|
837
|
+
{ key: "kind", match: { value: "summary" } },
|
|
838
|
+
{ key: "created_at", range: { gte: since } },
|
|
839
|
+
{ is_null: { key: "superseded_by" } },
|
|
840
|
+
] }, maxVal);
|
|
841
|
+
const summaries = summaryResults.result?.points ?? [];
|
|
842
|
+
if (summaries.length < 3) {
|
|
843
|
+
return {
|
|
844
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
845
|
+
action: "skipped",
|
|
846
|
+
reason: `Only ${summaries.length} session summaries in the last ${daysVal} days. Need at least 3.`,
|
|
847
|
+
}) }],
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const summaryTexts = summaries.map((s, i) => {
|
|
851
|
+
const p = s.payload;
|
|
852
|
+
const parts = [`Session ${i + 1} (${p.created_at}):\n${p.content}`];
|
|
853
|
+
if (p.tasks_completed?.length)
|
|
854
|
+
parts.push(`Tasks: ${p.tasks_completed.join(", ")}`);
|
|
855
|
+
if (p.decisions_made?.length)
|
|
856
|
+
parts.push(`Decisions: ${p.decisions_made.join("; ")}`);
|
|
857
|
+
return parts.join("\n");
|
|
858
|
+
}).join("\n\n---\n\n");
|
|
859
|
+
const systemPrompt = "You are a memory consolidation system. Given session summaries from an engineering agent, " +
|
|
860
|
+
"extract recurring patterns, consolidated learnings, and key facts. Output:\n" +
|
|
861
|
+
"1. Recurring patterns (things that keep coming up)\n" +
|
|
862
|
+
"2. Key infrastructure/project facts learned\n" +
|
|
863
|
+
"3. Decisions made and their rationale\n" +
|
|
864
|
+
"4. Open issues or recurring problems\n" +
|
|
865
|
+
"Be concise — one line per point. Omit ephemeral details.";
|
|
866
|
+
let distilledContent;
|
|
867
|
+
try {
|
|
868
|
+
distilledContent = await chatComplete(systemPrompt, summaryTexts);
|
|
869
|
+
}
|
|
870
|
+
catch (e) {
|
|
871
|
+
return { content: [{ type: "text", text: `Distillation failed: ${e instanceof Error ? e.message : String(e)}` }] };
|
|
872
|
+
}
|
|
873
|
+
const allEntities = [...new Set(summaries.flatMap((s) => s.payload.entities ?? []))];
|
|
874
|
+
const vector = await embed(distilledContent);
|
|
875
|
+
const factId = newId();
|
|
876
|
+
const sourceIds = summaries.map((s) => s.id);
|
|
877
|
+
await qdrantUpsert(factId, vector, {
|
|
878
|
+
content: distilledContent,
|
|
879
|
+
category: "observation",
|
|
880
|
+
domain: "work",
|
|
881
|
+
kind: "distilled",
|
|
882
|
+
entities: allEntities,
|
|
883
|
+
source: "system",
|
|
884
|
+
confidence: 0.9,
|
|
885
|
+
content_hash: contentHash("observation", distilledContent),
|
|
886
|
+
reinforcement_count: 1,
|
|
887
|
+
last_reinforced_at: now,
|
|
888
|
+
superseded_by: null,
|
|
889
|
+
superseded_at: null,
|
|
890
|
+
created_at: now,
|
|
891
|
+
updated_at: now,
|
|
892
|
+
distilled_from: sourceIds,
|
|
893
|
+
distilled_period_start: since,
|
|
894
|
+
distilled_period_end: now,
|
|
895
|
+
summary_count: summaries.length,
|
|
896
|
+
});
|
|
897
|
+
try {
|
|
898
|
+
await qdrantSetPayload(sourceIds, {
|
|
899
|
+
superseded_by: factId,
|
|
900
|
+
superseded_at: now,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
catch (e) {
|
|
904
|
+
log("WARN", `Failed to supersede some source summaries: ${e instanceof Error ? e.message : String(e)}`);
|
|
905
|
+
}
|
|
906
|
+
return {
|
|
907
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
908
|
+
action: "distilled",
|
|
909
|
+
fact_id: factId,
|
|
910
|
+
summaries_consolidated: summaries.length,
|
|
911
|
+
period: { start: since, end: now },
|
|
912
|
+
entities: allEntities,
|
|
913
|
+
preview: distilledContent.substring(0, 300) + (distilledContent.length > 300 ? "..." : ""),
|
|
914
|
+
}, null, 2) }],
|
|
915
|
+
};
|
|
916
|
+
});
|
|
917
|
+
// ── memory_heartbeat ────────────────────────────────────────────────────
|
|
918
|
+
mcp.tool("memory_heartbeat", "Lightweight reflection check. Returns memory nudge (if no stores in 10+ min), staleness alerts (every 3rd call), and reflection prompt.", {}, async () => {
|
|
919
|
+
heartbeatCount++;
|
|
920
|
+
const sections = [];
|
|
921
|
+
const nudge = buildMemoryNudge();
|
|
922
|
+
if (nudge)
|
|
923
|
+
sections.push(nudge);
|
|
924
|
+
if (heartbeatCount % 3 === 0 && ready) {
|
|
925
|
+
try {
|
|
926
|
+
const staleThreshold = new Date(Date.now() - STALENESS_DAYS * 86400000).toISOString();
|
|
927
|
+
const staleResults = await qdrantScroll({ must: [
|
|
928
|
+
{ key: "category", match: { any: ["infrastructure", "projects", "decisions"] } },
|
|
929
|
+
{ is_null: { key: "superseded_by" } },
|
|
930
|
+
],
|
|
931
|
+
should: [
|
|
932
|
+
{ key: "last_reinforced_at", range: { lte: staleThreshold } },
|
|
933
|
+
{ is_null: { key: "last_reinforced_at" } },
|
|
934
|
+
],
|
|
935
|
+
must_not: [
|
|
936
|
+
{ key: "last_verified_at", range: { gte: staleThreshold } },
|
|
937
|
+
] }, 3);
|
|
938
|
+
const staleFacts = staleResults.result?.points ?? [];
|
|
939
|
+
if (staleFacts.length > 0) {
|
|
940
|
+
const staleLines = staleFacts.map((f) => {
|
|
941
|
+
const d = Math.round(daysSince(lastActivityDate(f.payload)));
|
|
942
|
+
return ` • [${f.payload.category}] ${f.payload.content} (${d}d old, id: ${f.id})`;
|
|
943
|
+
});
|
|
944
|
+
sections.push(`🕰️ **Stale facts** — these haven't been verified in ${STALENESS_DAYS}+ days. Still accurate?\n` +
|
|
945
|
+
staleLines.join("\n") +
|
|
946
|
+
"\n → Use `memory_verify(fact_id)` to confirm, `memory_forget(fact_id, reason)` to retire, or `memory_store(supersedes: fact_id)` to replace.");
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
catch (e) {
|
|
950
|
+
log("WARN", `Staleness check failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
sections.push("🔍 **Reflect:** What have you learned, decided, or discovered since the last heartbeat? " +
|
|
954
|
+
"If anything is worth persisting for future sessions, call memory_store now.");
|
|
955
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }] };
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
//# sourceMappingURL=tools.js.map
|