memory-crystal 0.2.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/.env.example +20 -0
- package/CHANGELOG.md +6 -0
- package/LETTERS.md +22 -0
- package/LICENSE +21 -0
- package/README-ENTERPRISE.md +162 -0
- package/README-old.md +275 -0
- package/README.md +91 -0
- package/RELAY.md +88 -0
- package/TECHNICAL.md +379 -0
- package/ai/dev-updates/2026-02-25--cc-air--phase2-architecture-pivot.md +70 -0
- package/ai/dev-updates/2026-02-25--cc-air--phase2-worker-build.md +72 -0
- package/ai/dev-updates/2026-02-26--10-25-16--cc-mini--phase2-implementation.md +49 -0
- package/ai/dev-updates/2026-02-27--20-30-00--cc-mini--readme-overhaul-and-public-deploy.md +69 -0
- package/ai/notes/2026-02-26--cc-air--notes.md +412 -0
- package/ai/notes/2026-02-27--cc-mini--grok-feedback.md +44 -0
- package/ai/notes/2026-02-27--cc-mini--lesa-feedback.md +45 -0
- package/ai/notes/RESEARCH.md +1185 -0
- package/ai/notes/salience-research/README.md +29 -0
- package/ai/notes/salience-research/eurosla-salience-review.md +64 -0
- package/ai/notes/salience-research/full-research-summary.md +269 -0
- package/ai/notes/salience-research/salience-levels-diagram.png +0 -0
- package/ai/plan/2026-02-27--cc-mini--qr-pairing-spec.md +203 -0
- package/ai/plan/_archive/PLAN.md +194 -0
- package/ai/plan/_archive/PRD.md +1014 -0
- package/ai/plan/cc-plans-duplicates-from-dot-claude/2026-02-26--cc-mini--phase2-implementation-plan.md +245 -0
- package/ai/plan/dev-conventions-note.md +70 -0
- package/ai/plan/ldm-os-install-and-boot-architecture.md +285 -0
- package/ai/plan/memory-crystal-phase2-plan.md +192 -0
- package/ai/plan/memory-system-lay-of-the-land.md +214 -0
- package/ai/plan/phase2-ephemeral-relay.md +238 -0
- package/ai/plan/readme-first.md +68 -0
- package/ai/plan/roadmap.md +159 -0
- package/ai/todos/PUNCHLIST.md +44 -0
- package/ai/todos/README.md +31 -0
- package/ai/todos/inboxes/cc-air/2026-02-26--cc-air--post-relay-todos.md +85 -0
- package/ai/todos/inboxes/cc-mini/2026-02-26--cc-mini--phase2-status.md +100 -0
- package/ai/todos/inboxes/cc-mini/_archive/TODO.md +25 -0
- package/ai/todos/inboxes/parker/2026-02-25--cc-air--setup-checklist.md +139 -0
- package/ai/todos/inboxes/parker/2026-02-26--cc-mini--phase2-your-moves.md +72 -0
- package/dist/cc-hook.d.ts +1 -0
- package/dist/cc-hook.js +349 -0
- package/dist/chunk-3VFIJYS4.js +818 -0
- package/dist/chunk-52QE3YI3.js +1169 -0
- package/dist/chunk-AA3OPP4Z.js +432 -0
- package/dist/chunk-D3I3ZSE2.js +411 -0
- package/dist/chunk-EKSACBTJ.js +1070 -0
- package/dist/chunk-F3Y7EL7K.js +83 -0
- package/dist/chunk-JWZXYVET.js +1068 -0
- package/dist/chunk-KYVWO6ZM.js +1069 -0
- package/dist/chunk-L3VHARQH.js +413 -0
- package/dist/chunk-LOVAHSQV.js +411 -0
- package/dist/chunk-LQOYCAGG.js +446 -0
- package/dist/chunk-MK42FMEG.js +147 -0
- package/dist/chunk-NIJCVN3O.js +147 -0
- package/dist/chunk-O2UITJGH.js +465 -0
- package/dist/chunk-PEK6JH65.js +432 -0
- package/dist/chunk-PJ6FFKEX.js +77 -0
- package/dist/chunk-PLUBBZYR.js +800 -0
- package/dist/chunk-SGL6ISBJ.js +1061 -0
- package/dist/chunk-UNHVZB5G.js +411 -0
- package/dist/chunk-VAFTWSTE.js +1061 -0
- package/dist/chunk-XZ3S56RQ.js +1061 -0
- package/dist/chunk-Y72C7F6O.js +148 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +325 -0
- package/dist/core.d.ts +188 -0
- package/dist/core.js +12 -0
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.js +18 -0
- package/dist/dev-update-SZ2Z4WCQ.js +6 -0
- package/dist/ldm.d.ts +17 -0
- package/dist/ldm.js +12 -0
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +250 -0
- package/dist/migrate.d.ts +1 -0
- package/dist/migrate.js +89 -0
- package/dist/mirror-sync.d.ts +1 -0
- package/dist/mirror-sync.js +130 -0
- package/dist/openclaw.d.ts +5 -0
- package/dist/openclaw.js +349 -0
- package/dist/poller.d.ts +1 -0
- package/dist/poller.js +272 -0
- package/dist/summarize.d.ts +19 -0
- package/dist/summarize.js +10 -0
- package/dist/worker.js +137 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +40 -0
- package/scripts/migrate-lance-to-sqlite.mjs +217 -0
- package/skills/memory/SKILL.md +61 -0
- package/src/cc-hook.ts +447 -0
- package/src/cli.ts +356 -0
- package/src/core.ts +1472 -0
- package/src/crypto.ts +113 -0
- package/src/dev-update.ts +178 -0
- package/src/ldm.ts +117 -0
- package/src/mcp-server.ts +274 -0
- package/src/migrate.ts +104 -0
- package/src/mirror-sync.ts +175 -0
- package/src/openclaw.ts +250 -0
- package/src/poller.ts +345 -0
- package/src/summarize.ts +210 -0
- package/src/worker.ts +208 -0
- package/tsconfig.json +18 -0
- package/wrangler.toml +20 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
import * as lancedb from "@lancedb/lancedb";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
import { join, relative, extname, basename } from "path";
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import http from "http";
|
|
9
|
+
import https from "https";
|
|
10
|
+
async function embedOpenAI(texts, apiKey, model) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const body = JSON.stringify({ input: texts, model });
|
|
13
|
+
const req = https.request({
|
|
14
|
+
hostname: "api.openai.com",
|
|
15
|
+
path: "/v1/embeddings",
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
20
|
+
"Content-Length": Buffer.byteLength(body)
|
|
21
|
+
},
|
|
22
|
+
timeout: 3e4
|
|
23
|
+
}, (res) => {
|
|
24
|
+
let data = "";
|
|
25
|
+
res.on("data", (chunk) => data += chunk);
|
|
26
|
+
res.on("end", () => {
|
|
27
|
+
if (res.statusCode !== 200) {
|
|
28
|
+
reject(new Error(`OpenAI API error ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const parsed = JSON.parse(data);
|
|
32
|
+
resolve(parsed.data.map((d) => d.embedding));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
req.on("error", reject);
|
|
36
|
+
req.on("timeout", () => {
|
|
37
|
+
req.destroy();
|
|
38
|
+
reject(new Error("OpenAI timeout"));
|
|
39
|
+
});
|
|
40
|
+
req.write(body);
|
|
41
|
+
req.end();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function embedOllama(texts, host, model) {
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const text of texts) {
|
|
47
|
+
const result = await new Promise((resolve, reject) => {
|
|
48
|
+
const url = new URL("/api/embeddings", host);
|
|
49
|
+
const body = JSON.stringify({ model, prompt: text });
|
|
50
|
+
const req = http.request({
|
|
51
|
+
hostname: url.hostname,
|
|
52
|
+
port: url.port,
|
|
53
|
+
path: url.pathname,
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
"Content-Length": Buffer.byteLength(body)
|
|
58
|
+
},
|
|
59
|
+
timeout: 15e3
|
|
60
|
+
}, (res) => {
|
|
61
|
+
let data = "";
|
|
62
|
+
res.on("data", (chunk) => data += chunk);
|
|
63
|
+
res.on("end", () => {
|
|
64
|
+
if (res.statusCode !== 200) {
|
|
65
|
+
reject(new Error(`Ollama error ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
resolve(JSON.parse(data).embedding);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
req.on("error", reject);
|
|
72
|
+
req.on("timeout", () => {
|
|
73
|
+
req.destroy();
|
|
74
|
+
reject(new Error("Ollama timeout"));
|
|
75
|
+
});
|
|
76
|
+
req.write(body);
|
|
77
|
+
req.end();
|
|
78
|
+
});
|
|
79
|
+
results.push(result);
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
async function embedGoogle(texts, apiKey, model) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const body = JSON.stringify({
|
|
86
|
+
requests: texts.map((text) => ({ model: `models/${model}`, content: { parts: [{ text }] } }))
|
|
87
|
+
});
|
|
88
|
+
const req = https.request({
|
|
89
|
+
hostname: "generativelanguage.googleapis.com",
|
|
90
|
+
path: `/v1beta/models/${model}:batchEmbedContents?key=${apiKey}`,
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
"Content-Length": Buffer.byteLength(body)
|
|
95
|
+
},
|
|
96
|
+
timeout: 3e4
|
|
97
|
+
}, (res) => {
|
|
98
|
+
let data = "";
|
|
99
|
+
res.on("data", (chunk) => data += chunk);
|
|
100
|
+
res.on("end", () => {
|
|
101
|
+
if (res.statusCode !== 200) {
|
|
102
|
+
reject(new Error(`Google API error ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const parsed = JSON.parse(data);
|
|
106
|
+
resolve(parsed.embeddings.map((e) => e.values));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
req.on("error", reject);
|
|
110
|
+
req.on("timeout", () => {
|
|
111
|
+
req.destroy();
|
|
112
|
+
reject(new Error("Google timeout"));
|
|
113
|
+
});
|
|
114
|
+
req.write(body);
|
|
115
|
+
req.end();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
var Crystal = class _Crystal {
|
|
119
|
+
config;
|
|
120
|
+
lanceDb = null;
|
|
121
|
+
sqliteDb = null;
|
|
122
|
+
chunksTable = null;
|
|
123
|
+
constructor(config) {
|
|
124
|
+
this.config = config;
|
|
125
|
+
if (!existsSync(config.dataDir)) {
|
|
126
|
+
mkdirSync(config.dataDir, { recursive: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ── Initialization ──
|
|
130
|
+
async init() {
|
|
131
|
+
const lanceDir = join(this.config.dataDir, "lance");
|
|
132
|
+
const sqlitePath = join(this.config.dataDir, "crystal.db");
|
|
133
|
+
if (!existsSync(lanceDir)) mkdirSync(lanceDir, { recursive: true });
|
|
134
|
+
this.lanceDb = await lancedb.connect(lanceDir);
|
|
135
|
+
this.sqliteDb = new Database(sqlitePath);
|
|
136
|
+
this.sqliteDb.pragma("journal_mode = WAL");
|
|
137
|
+
this.initSqliteTables();
|
|
138
|
+
await this.initLanceTables();
|
|
139
|
+
}
|
|
140
|
+
initSqliteTables() {
|
|
141
|
+
const db = this.sqliteDb;
|
|
142
|
+
db.exec(`
|
|
143
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
144
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
+
type TEXT NOT NULL,
|
|
146
|
+
uri TEXT NOT NULL,
|
|
147
|
+
title TEXT,
|
|
148
|
+
agent_id TEXT NOT NULL,
|
|
149
|
+
metadata TEXT DEFAULT '{}',
|
|
150
|
+
ingested_at TEXT NOT NULL,
|
|
151
|
+
chunk_count INTEGER DEFAULT 0
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
CREATE TABLE IF NOT EXISTS capture_state (
|
|
155
|
+
agent_id TEXT NOT NULL,
|
|
156
|
+
source_id TEXT NOT NULL,
|
|
157
|
+
last_message_count INTEGER DEFAULT 0,
|
|
158
|
+
capture_count INTEGER DEFAULT 0,
|
|
159
|
+
last_capture_at TEXT,
|
|
160
|
+
PRIMARY KEY (agent_id, source_id)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
164
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
165
|
+
text TEXT NOT NULL,
|
|
166
|
+
category TEXT NOT NULL DEFAULT 'fact',
|
|
167
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
168
|
+
source_ids TEXT DEFAULT '[]',
|
|
169
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
170
|
+
created_at TEXT NOT NULL,
|
|
171
|
+
updated_at TEXT NOT NULL
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
name TEXT NOT NULL UNIQUE,
|
|
177
|
+
type TEXT NOT NULL DEFAULT 'concept',
|
|
178
|
+
description TEXT,
|
|
179
|
+
properties TEXT DEFAULT '{}',
|
|
180
|
+
created_at TEXT NOT NULL,
|
|
181
|
+
updated_at TEXT NOT NULL
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
CREATE TABLE IF NOT EXISTS relationships (
|
|
185
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
186
|
+
source_id INTEGER NOT NULL REFERENCES entities(id),
|
|
187
|
+
target_id INTEGER NOT NULL REFERENCES entities(id),
|
|
188
|
+
type TEXT NOT NULL,
|
|
189
|
+
description TEXT,
|
|
190
|
+
weight REAL DEFAULT 1.0,
|
|
191
|
+
valid_from TEXT NOT NULL,
|
|
192
|
+
valid_until TEXT,
|
|
193
|
+
created_at TEXT NOT NULL
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
CREATE INDEX IF NOT EXISTS idx_sources_agent ON sources(agent_id);
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
|
|
198
|
+
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_relationships_source ON relationships(source_id);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_relationships_target ON relationships(target_id);
|
|
201
|
+
|
|
202
|
+
-- Source file indexing (optional feature)
|
|
203
|
+
CREATE TABLE IF NOT EXISTS source_collections (
|
|
204
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
205
|
+
name TEXT NOT NULL UNIQUE,
|
|
206
|
+
root_path TEXT NOT NULL,
|
|
207
|
+
glob_patterns TEXT NOT NULL DEFAULT '["**/*"]',
|
|
208
|
+
ignore_patterns TEXT NOT NULL DEFAULT '[]',
|
|
209
|
+
file_count INTEGER DEFAULT 0,
|
|
210
|
+
chunk_count INTEGER DEFAULT 0,
|
|
211
|
+
last_sync_at TEXT,
|
|
212
|
+
created_at TEXT NOT NULL
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
CREATE TABLE IF NOT EXISTS source_files (
|
|
216
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
217
|
+
collection_id INTEGER NOT NULL REFERENCES source_collections(id) ON DELETE CASCADE,
|
|
218
|
+
file_path TEXT NOT NULL,
|
|
219
|
+
file_hash TEXT NOT NULL,
|
|
220
|
+
file_size INTEGER NOT NULL,
|
|
221
|
+
chunk_count INTEGER DEFAULT 0,
|
|
222
|
+
last_indexed_at TEXT NOT NULL
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_source_files_path ON source_files(collection_id, file_path);
|
|
226
|
+
CREATE INDEX IF NOT EXISTS idx_source_files_collection ON source_files(collection_id);
|
|
227
|
+
`);
|
|
228
|
+
}
|
|
229
|
+
async initLanceTables() {
|
|
230
|
+
const db = this.lanceDb;
|
|
231
|
+
const tableNames = await db.tableNames();
|
|
232
|
+
if (tableNames.includes("chunks")) {
|
|
233
|
+
this.chunksTable = await db.openTable("chunks");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// ── Embedding ──
|
|
237
|
+
async embed(texts) {
|
|
238
|
+
if (texts.length === 0) return [];
|
|
239
|
+
const cfg = this.config;
|
|
240
|
+
switch (cfg.embeddingProvider) {
|
|
241
|
+
case "openai": {
|
|
242
|
+
if (!cfg.openaiApiKey) throw new Error("OpenAI API key required");
|
|
243
|
+
const model = cfg.openaiModel || "text-embedding-3-small";
|
|
244
|
+
const maxCharsPerBatch = 8e5;
|
|
245
|
+
const results = [];
|
|
246
|
+
let batch = [];
|
|
247
|
+
let batchChars = 0;
|
|
248
|
+
for (const text of texts) {
|
|
249
|
+
if (batchChars + text.length > maxCharsPerBatch && batch.length > 0) {
|
|
250
|
+
results.push(...await embedOpenAI(batch, cfg.openaiApiKey, model));
|
|
251
|
+
batch = [];
|
|
252
|
+
batchChars = 0;
|
|
253
|
+
}
|
|
254
|
+
batch.push(text);
|
|
255
|
+
batchChars += text.length;
|
|
256
|
+
}
|
|
257
|
+
if (batch.length > 0) {
|
|
258
|
+
results.push(...await embedOpenAI(batch, cfg.openaiApiKey, model));
|
|
259
|
+
}
|
|
260
|
+
return results;
|
|
261
|
+
}
|
|
262
|
+
case "ollama":
|
|
263
|
+
return embedOllama(texts, cfg.ollamaHost || "http://localhost:11434", cfg.ollamaModel || "nomic-embed-text");
|
|
264
|
+
case "google":
|
|
265
|
+
if (!cfg.googleApiKey) throw new Error("Google API key required");
|
|
266
|
+
return embedGoogle(texts, cfg.googleApiKey, cfg.googleModel || "text-embedding-004");
|
|
267
|
+
default:
|
|
268
|
+
throw new Error(`Unknown embedding provider: ${cfg.embeddingProvider}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// ── Chunking ──
|
|
272
|
+
chunkText(text, targetTokens = 400, overlapTokens = 80) {
|
|
273
|
+
const targetChars = targetTokens * 4;
|
|
274
|
+
const overlapChars = overlapTokens * 4;
|
|
275
|
+
const chunks = [];
|
|
276
|
+
let start = 0;
|
|
277
|
+
while (start < text.length) {
|
|
278
|
+
let end = Math.min(start + targetChars, text.length);
|
|
279
|
+
if (end < text.length) {
|
|
280
|
+
const minBreak = start + Math.floor(targetChars * 0.5);
|
|
281
|
+
const paraBreak = text.lastIndexOf("\n\n", end);
|
|
282
|
+
if (paraBreak > minBreak) {
|
|
283
|
+
end = paraBreak;
|
|
284
|
+
} else {
|
|
285
|
+
const sentBreak = text.lastIndexOf(". ", end);
|
|
286
|
+
if (sentBreak > minBreak) {
|
|
287
|
+
end = sentBreak + 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const chunk = text.slice(start, end).trim();
|
|
292
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
293
|
+
if (end >= text.length) break;
|
|
294
|
+
start = end - overlapChars;
|
|
295
|
+
if (start <= (chunks.length > 0 ? end - targetChars : 0)) {
|
|
296
|
+
start = end;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return chunks;
|
|
300
|
+
}
|
|
301
|
+
// ── Ingest ──
|
|
302
|
+
async ingest(chunks) {
|
|
303
|
+
if (chunks.length === 0) return 0;
|
|
304
|
+
const texts = chunks.map((c) => c.text);
|
|
305
|
+
const embeddings = await this.embed(texts);
|
|
306
|
+
const records = chunks.map((chunk, i) => ({
|
|
307
|
+
text: chunk.text,
|
|
308
|
+
vector: embeddings[i],
|
|
309
|
+
role: chunk.role,
|
|
310
|
+
source_type: chunk.source_type,
|
|
311
|
+
source_id: chunk.source_id,
|
|
312
|
+
agent_id: chunk.agent_id,
|
|
313
|
+
token_count: chunk.token_count,
|
|
314
|
+
created_at: chunk.created_at || (/* @__PURE__ */ new Date()).toISOString()
|
|
315
|
+
}));
|
|
316
|
+
if (!this.chunksTable) {
|
|
317
|
+
this.chunksTable = await this.lanceDb.createTable("chunks", records);
|
|
318
|
+
} else {
|
|
319
|
+
await this.chunksTable.add(records);
|
|
320
|
+
}
|
|
321
|
+
return records.length;
|
|
322
|
+
}
|
|
323
|
+
// ── Recency helpers ──
|
|
324
|
+
recencyWeight(ageDays) {
|
|
325
|
+
return Math.max(0.5, 1 - ageDays * 0.01);
|
|
326
|
+
}
|
|
327
|
+
freshnessLabel(ageDays) {
|
|
328
|
+
if (ageDays < 3) return "fresh";
|
|
329
|
+
if (ageDays < 7) return "recent";
|
|
330
|
+
if (ageDays < 14) return "aging";
|
|
331
|
+
return "stale";
|
|
332
|
+
}
|
|
333
|
+
// ── Search ──
|
|
334
|
+
async search(query, limit = 5, filter) {
|
|
335
|
+
if (!this.chunksTable) return [];
|
|
336
|
+
const [embedding] = await this.embed([query]);
|
|
337
|
+
const fetchLimit = Math.max(limit * 3, 30);
|
|
338
|
+
let queryBuilder = this.chunksTable.vectorSearch(embedding).distanceType("cosine").limit(fetchLimit);
|
|
339
|
+
if (filter?.agent_id) {
|
|
340
|
+
queryBuilder = queryBuilder.where(`agent_id = '${filter.agent_id}'`);
|
|
341
|
+
}
|
|
342
|
+
if (filter?.source_type) {
|
|
343
|
+
queryBuilder = queryBuilder.where(`source_type = '${filter.source_type}'`);
|
|
344
|
+
}
|
|
345
|
+
const results = await queryBuilder.toArray();
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
return results.map((row) => {
|
|
348
|
+
const cosine = row._distance != null ? 1 - row._distance : 0;
|
|
349
|
+
const createdAt = row.created_at || "";
|
|
350
|
+
const ageDays = createdAt ? (now - new Date(createdAt).getTime()) / (1e3 * 60 * 60 * 24) : 0;
|
|
351
|
+
const weight = createdAt ? this.recencyWeight(ageDays) : 1;
|
|
352
|
+
return {
|
|
353
|
+
text: row.text,
|
|
354
|
+
role: row.role,
|
|
355
|
+
score: cosine * weight,
|
|
356
|
+
source_type: row.source_type,
|
|
357
|
+
source_id: row.source_id,
|
|
358
|
+
agent_id: row.agent_id,
|
|
359
|
+
created_at: createdAt,
|
|
360
|
+
freshness: createdAt ? this.freshnessLabel(ageDays) : void 0
|
|
361
|
+
};
|
|
362
|
+
}).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
363
|
+
}
|
|
364
|
+
// ── Remember (explicit fact storage) ──
|
|
365
|
+
async remember(text, category = "fact") {
|
|
366
|
+
const db = this.sqliteDb;
|
|
367
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
368
|
+
const stmt = db.prepare(`
|
|
369
|
+
INSERT INTO memories (text, category, confidence, source_ids, status, created_at, updated_at)
|
|
370
|
+
VALUES (?, ?, 1.0, '[]', 'active', ?, ?)
|
|
371
|
+
`);
|
|
372
|
+
const result = stmt.run(text, category, now, now);
|
|
373
|
+
await this.ingest([{
|
|
374
|
+
text,
|
|
375
|
+
role: "system",
|
|
376
|
+
source_type: "manual",
|
|
377
|
+
source_id: `memory:${result.lastInsertRowid}`,
|
|
378
|
+
agent_id: "system",
|
|
379
|
+
token_count: Math.ceil(text.length / 4),
|
|
380
|
+
created_at: now
|
|
381
|
+
}]);
|
|
382
|
+
return result.lastInsertRowid;
|
|
383
|
+
}
|
|
384
|
+
// ── Forget (deprecate a memory) ──
|
|
385
|
+
forget(memoryId) {
|
|
386
|
+
const db = this.sqliteDb;
|
|
387
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
388
|
+
const result = db.prepare(`
|
|
389
|
+
UPDATE memories SET status = 'deprecated', updated_at = ? WHERE id = ? AND status = 'active'
|
|
390
|
+
`).run(now, memoryId);
|
|
391
|
+
return result.changes > 0;
|
|
392
|
+
}
|
|
393
|
+
// ── Status ──
|
|
394
|
+
async status() {
|
|
395
|
+
const db = this.sqliteDb;
|
|
396
|
+
let chunks = 0;
|
|
397
|
+
let oldestChunk = null;
|
|
398
|
+
let newestChunk = null;
|
|
399
|
+
const agents = [];
|
|
400
|
+
if (this.chunksTable) {
|
|
401
|
+
chunks = await this.chunksTable.countRows();
|
|
402
|
+
try {
|
|
403
|
+
const sample = await this.chunksTable.search([]).limit(1).toArray();
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const memories = db.prepare("SELECT COUNT(*) as count FROM memories WHERE status = ?").get("active")?.count || 0;
|
|
408
|
+
const sources = db.prepare("SELECT COUNT(*) as count FROM sources").get()?.count || 0;
|
|
409
|
+
const sourceAgentRows = db.prepare("SELECT DISTINCT agent_id FROM sources").all();
|
|
410
|
+
const captureAgentRows = db.prepare("SELECT DISTINCT agent_id FROM capture_state").all();
|
|
411
|
+
const allAgents = [.../* @__PURE__ */ new Set([
|
|
412
|
+
...sourceAgentRows.map((r) => r.agent_id),
|
|
413
|
+
...captureAgentRows.map((r) => r.agent_id)
|
|
414
|
+
])];
|
|
415
|
+
agents.push(...allAgents);
|
|
416
|
+
const captureInfo = db.prepare(
|
|
417
|
+
"SELECT COUNT(*) as count, MAX(last_capture_at) as latest FROM capture_state"
|
|
418
|
+
).get();
|
|
419
|
+
return {
|
|
420
|
+
chunks,
|
|
421
|
+
memories,
|
|
422
|
+
sources,
|
|
423
|
+
agents,
|
|
424
|
+
oldestChunk,
|
|
425
|
+
newestChunk,
|
|
426
|
+
embeddingProvider: this.config.embeddingProvider,
|
|
427
|
+
dataDir: this.config.dataDir,
|
|
428
|
+
capturedSessions: captureInfo?.count || 0,
|
|
429
|
+
latestCapture: captureInfo?.latest || null
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
// ── Capture State (for incremental ingestion) ──
|
|
433
|
+
getCaptureState(agentId, sourceId) {
|
|
434
|
+
const db = this.sqliteDb;
|
|
435
|
+
const row = db.prepare("SELECT last_message_count, capture_count FROM capture_state WHERE agent_id = ? AND source_id = ?").get(agentId, sourceId);
|
|
436
|
+
if (!row) return { lastMessageCount: 0, captureCount: 0 };
|
|
437
|
+
return {
|
|
438
|
+
lastMessageCount: row.last_message_count,
|
|
439
|
+
captureCount: row.capture_count
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
setCaptureState(agentId, sourceId, messageCount, captureCount) {
|
|
443
|
+
const db = this.sqliteDb;
|
|
444
|
+
db.prepare(`
|
|
445
|
+
INSERT OR REPLACE INTO capture_state (agent_id, source_id, last_message_count, capture_count, last_capture_at)
|
|
446
|
+
VALUES (?, ?, ?, ?, ?)
|
|
447
|
+
`).run(agentId, sourceId, messageCount, captureCount, (/* @__PURE__ */ new Date()).toISOString());
|
|
448
|
+
}
|
|
449
|
+
// ── Source File Indexing (optional feature) ──
|
|
450
|
+
//
|
|
451
|
+
// Add directories as "collections", sync to index/re-index changed files.
|
|
452
|
+
// All source chunks get source_type='file' so they're searchable alongside
|
|
453
|
+
// conversations and memories. Nothing here is required... you can use MC
|
|
454
|
+
// without ever touching sources.
|
|
455
|
+
// Default patterns for files worth indexing
|
|
456
|
+
static DEFAULT_INCLUDE = [
|
|
457
|
+
"**/*.ts",
|
|
458
|
+
"**/*.js",
|
|
459
|
+
"**/*.tsx",
|
|
460
|
+
"**/*.jsx",
|
|
461
|
+
"**/*.py",
|
|
462
|
+
"**/*.rs",
|
|
463
|
+
"**/*.go",
|
|
464
|
+
"**/*.java",
|
|
465
|
+
"**/*.md",
|
|
466
|
+
"**/*.txt",
|
|
467
|
+
"**/*.json",
|
|
468
|
+
"**/*.yaml",
|
|
469
|
+
"**/*.yml",
|
|
470
|
+
"**/*.toml",
|
|
471
|
+
"**/*.sh",
|
|
472
|
+
"**/*.bash",
|
|
473
|
+
"**/*.zsh",
|
|
474
|
+
"**/*.css",
|
|
475
|
+
"**/*.html",
|
|
476
|
+
"**/*.svg",
|
|
477
|
+
"**/*.sql",
|
|
478
|
+
"**/*.graphql",
|
|
479
|
+
"**/*.c",
|
|
480
|
+
"**/*.cpp",
|
|
481
|
+
"**/*.h",
|
|
482
|
+
"**/*.hpp",
|
|
483
|
+
"**/*.swift",
|
|
484
|
+
"**/*.kt",
|
|
485
|
+
"**/*.rb",
|
|
486
|
+
"**/*.env.example",
|
|
487
|
+
"**/*.gitignore",
|
|
488
|
+
"**/Makefile",
|
|
489
|
+
"**/Dockerfile",
|
|
490
|
+
"**/Cargo.toml",
|
|
491
|
+
"**/package.json",
|
|
492
|
+
"**/tsconfig.json"
|
|
493
|
+
];
|
|
494
|
+
static DEFAULT_IGNORE = [
|
|
495
|
+
"**/node_modules/**",
|
|
496
|
+
"**/.git/**",
|
|
497
|
+
"**/dist/**",
|
|
498
|
+
"**/build/**",
|
|
499
|
+
"**/.next/**",
|
|
500
|
+
"**/.cache/**",
|
|
501
|
+
"**/coverage/**",
|
|
502
|
+
"**/__pycache__/**",
|
|
503
|
+
"**/target/**",
|
|
504
|
+
"**/vendor/**",
|
|
505
|
+
"**/.venv/**",
|
|
506
|
+
"**/*.lock",
|
|
507
|
+
"**/package-lock.json",
|
|
508
|
+
"**/yarn.lock",
|
|
509
|
+
"**/bun.lockb",
|
|
510
|
+
"**/*.min.js",
|
|
511
|
+
"**/*.min.css",
|
|
512
|
+
"**/*.map",
|
|
513
|
+
"**/*.png",
|
|
514
|
+
"**/*.jpg",
|
|
515
|
+
"**/*.jpeg",
|
|
516
|
+
"**/*.gif",
|
|
517
|
+
"**/*.ico",
|
|
518
|
+
"**/*.webp",
|
|
519
|
+
"**/*.woff",
|
|
520
|
+
"**/*.woff2",
|
|
521
|
+
"**/*.ttf",
|
|
522
|
+
"**/*.eot",
|
|
523
|
+
"**/*.mp3",
|
|
524
|
+
"**/*.mp4",
|
|
525
|
+
"**/*.wav",
|
|
526
|
+
"**/*.ogg",
|
|
527
|
+
"**/*.webm",
|
|
528
|
+
"**/*.zip",
|
|
529
|
+
"**/*.tar",
|
|
530
|
+
"**/*.gz",
|
|
531
|
+
"**/*.br",
|
|
532
|
+
"**/*.sqlite",
|
|
533
|
+
"**/*.db",
|
|
534
|
+
"**/*.lance/**",
|
|
535
|
+
"**/*.jsonl",
|
|
536
|
+
"**/secrets/**",
|
|
537
|
+
"**/.env"
|
|
538
|
+
];
|
|
539
|
+
/** Add a directory as a source collection for indexing. */
|
|
540
|
+
async sourcesAdd(rootPath, name, options) {
|
|
541
|
+
const db = this.sqliteDb;
|
|
542
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
543
|
+
const includePatterns = JSON.stringify(options?.include || _Crystal.DEFAULT_INCLUDE);
|
|
544
|
+
const ignorePatterns = JSON.stringify(options?.ignore || _Crystal.DEFAULT_IGNORE);
|
|
545
|
+
const existing = db.prepare("SELECT * FROM source_collections WHERE name = ?").get(name);
|
|
546
|
+
if (existing) {
|
|
547
|
+
throw new Error(`Collection "${name}" already exists. Use sourcesSync() to update it.`);
|
|
548
|
+
}
|
|
549
|
+
db.prepare(`
|
|
550
|
+
INSERT INTO source_collections (name, root_path, glob_patterns, ignore_patterns, created_at)
|
|
551
|
+
VALUES (?, ?, ?, ?, ?)
|
|
552
|
+
`).run(name, rootPath, includePatterns, ignorePatterns, now);
|
|
553
|
+
const row = db.prepare("SELECT * FROM source_collections WHERE name = ?").get(name);
|
|
554
|
+
return row;
|
|
555
|
+
}
|
|
556
|
+
/** Remove a source collection and its file records. Chunks remain in LanceDB. */
|
|
557
|
+
sourcesRemove(name) {
|
|
558
|
+
const db = this.sqliteDb;
|
|
559
|
+
const col = db.prepare("SELECT id FROM source_collections WHERE name = ?").get(name);
|
|
560
|
+
if (!col) return false;
|
|
561
|
+
db.prepare("DELETE FROM source_files WHERE collection_id = ?").run(col.id);
|
|
562
|
+
db.prepare("DELETE FROM source_collections WHERE id = ?").run(col.id);
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
/** Sync a collection: scan files, detect changes, re-index what changed. */
|
|
566
|
+
async sourcesSync(name, options) {
|
|
567
|
+
const db = this.sqliteDb;
|
|
568
|
+
const startTime = Date.now();
|
|
569
|
+
const batchSize = options?.batchSize || 20;
|
|
570
|
+
const col = db.prepare("SELECT * FROM source_collections WHERE name = ?").get(name);
|
|
571
|
+
if (!col) throw new Error(`Collection "${name}" not found. Add it first with sourcesAdd().`);
|
|
572
|
+
const includePatterns = JSON.parse(col.glob_patterns);
|
|
573
|
+
const ignorePatterns = JSON.parse(col.ignore_patterns);
|
|
574
|
+
const files = this.scanDirectory(col.root_path, includePatterns, ignorePatterns);
|
|
575
|
+
const existingFiles = /* @__PURE__ */ new Map();
|
|
576
|
+
const rows = db.prepare("SELECT id, file_path, file_hash FROM source_files WHERE collection_id = ?").all(col.id);
|
|
577
|
+
for (const row of rows) {
|
|
578
|
+
existingFiles.set(row.file_path, { id: row.id, file_hash: row.file_hash });
|
|
579
|
+
}
|
|
580
|
+
let added = 0;
|
|
581
|
+
let updated = 0;
|
|
582
|
+
let removed = 0;
|
|
583
|
+
let chunksAdded = 0;
|
|
584
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
585
|
+
const toIndex = [];
|
|
586
|
+
for (const absPath of files) {
|
|
587
|
+
const relPath = relative(col.root_path, absPath);
|
|
588
|
+
let content;
|
|
589
|
+
try {
|
|
590
|
+
content = readFileSync(absPath, "utf-8");
|
|
591
|
+
} catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const stat = statSync(absPath);
|
|
595
|
+
if (stat.size > 500 * 1024) continue;
|
|
596
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
597
|
+
const existing = existingFiles.get(relPath);
|
|
598
|
+
if (existing) {
|
|
599
|
+
existingFiles.delete(relPath);
|
|
600
|
+
if (existing.file_hash === hash) continue;
|
|
601
|
+
toIndex.push({ relPath, absPath, hash, size: stat.size, isUpdate: true });
|
|
602
|
+
} else {
|
|
603
|
+
toIndex.push({ relPath, absPath, hash, size: stat.size, isUpdate: false });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (options?.dryRun) {
|
|
607
|
+
const newFiles = toIndex.filter((f) => !f.isUpdate).length;
|
|
608
|
+
const updatedFiles = toIndex.filter((f) => f.isUpdate).length;
|
|
609
|
+
return {
|
|
610
|
+
collection: name,
|
|
611
|
+
added: newFiles,
|
|
612
|
+
updated: updatedFiles,
|
|
613
|
+
removed: existingFiles.size,
|
|
614
|
+
chunks_added: 0,
|
|
615
|
+
duration_ms: Date.now() - startTime
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
for (let i = 0; i < toIndex.length; i += batchSize) {
|
|
619
|
+
const batch = toIndex.slice(i, i + batchSize);
|
|
620
|
+
const allChunks = [];
|
|
621
|
+
for (const file of batch) {
|
|
622
|
+
const content = readFileSync(file.absPath, "utf-8");
|
|
623
|
+
const ext = extname(file.absPath);
|
|
624
|
+
const fileName = basename(file.absPath);
|
|
625
|
+
const header = `File: ${file.relPath}
|
|
626
|
+
|
|
627
|
+
`;
|
|
628
|
+
const textChunks = this.chunkText(header + content, 400, 80);
|
|
629
|
+
const fileChunks = textChunks.map((text) => ({
|
|
630
|
+
text,
|
|
631
|
+
role: "system",
|
|
632
|
+
source_type: "file",
|
|
633
|
+
source_id: `file:${name}:${file.relPath}`,
|
|
634
|
+
agent_id: "system",
|
|
635
|
+
token_count: Math.ceil(text.length / 4),
|
|
636
|
+
created_at: now
|
|
637
|
+
}));
|
|
638
|
+
allChunks.push(...fileChunks);
|
|
639
|
+
if (file.isUpdate) {
|
|
640
|
+
db.prepare(`
|
|
641
|
+
UPDATE source_files SET file_hash = ?, file_size = ?, chunk_count = ?, last_indexed_at = ?
|
|
642
|
+
WHERE collection_id = ? AND file_path = ?
|
|
643
|
+
`).run(file.hash, file.size, fileChunks.length, now, col.id, file.relPath);
|
|
644
|
+
updated++;
|
|
645
|
+
} else {
|
|
646
|
+
db.prepare(`
|
|
647
|
+
INSERT INTO source_files (collection_id, file_path, file_hash, file_size, chunk_count, last_indexed_at)
|
|
648
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
649
|
+
`).run(col.id, file.relPath, file.hash, file.size, fileChunks.length, now);
|
|
650
|
+
added++;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (allChunks.length > 0) {
|
|
654
|
+
const ingested = await this.ingest(allChunks);
|
|
655
|
+
chunksAdded += ingested;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
for (const [relPath, { id }] of existingFiles) {
|
|
659
|
+
db.prepare("DELETE FROM source_files WHERE id = ?").run(id);
|
|
660
|
+
removed++;
|
|
661
|
+
}
|
|
662
|
+
const fileCount = db.prepare("SELECT COUNT(*) as count FROM source_files WHERE collection_id = ?").get(col.id).count;
|
|
663
|
+
const chunkCount = db.prepare("SELECT SUM(chunk_count) as total FROM source_files WHERE collection_id = ?").get(col.id).total || 0;
|
|
664
|
+
db.prepare("UPDATE source_collections SET file_count = ?, chunk_count = ?, last_sync_at = ? WHERE id = ?").run(fileCount, chunkCount, now, col.id);
|
|
665
|
+
return {
|
|
666
|
+
collection: name,
|
|
667
|
+
added,
|
|
668
|
+
updated,
|
|
669
|
+
removed,
|
|
670
|
+
chunks_added: chunksAdded,
|
|
671
|
+
duration_ms: Date.now() - startTime
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/** Get status of all source collections. */
|
|
675
|
+
sourcesStatus() {
|
|
676
|
+
const db = this.sqliteDb;
|
|
677
|
+
const collections = db.prepare("SELECT name, root_path, file_count, chunk_count, last_sync_at FROM source_collections").all();
|
|
678
|
+
const totalFiles = collections.reduce((sum, c) => sum + c.file_count, 0);
|
|
679
|
+
const totalChunks = collections.reduce((sum, c) => sum + c.chunk_count, 0);
|
|
680
|
+
return {
|
|
681
|
+
collections: collections.map((c) => ({
|
|
682
|
+
name: c.name,
|
|
683
|
+
root_path: c.root_path,
|
|
684
|
+
file_count: c.file_count,
|
|
685
|
+
chunk_count: c.chunk_count,
|
|
686
|
+
last_sync_at: c.last_sync_at
|
|
687
|
+
})),
|
|
688
|
+
total_files: totalFiles,
|
|
689
|
+
total_chunks: totalChunks
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** Scan a directory recursively, matching include/ignore patterns. */
|
|
693
|
+
scanDirectory(rootPath, includePatterns, ignorePatterns) {
|
|
694
|
+
const results = [];
|
|
695
|
+
const allowedExtensions = /* @__PURE__ */ new Set();
|
|
696
|
+
const allowedExactNames = /* @__PURE__ */ new Set();
|
|
697
|
+
for (const pattern of includePatterns) {
|
|
698
|
+
const extMatch = pattern.match(/\*\*\/\*(\.\w+)$/);
|
|
699
|
+
if (extMatch) {
|
|
700
|
+
allowedExtensions.add(extMatch[1]);
|
|
701
|
+
}
|
|
702
|
+
const nameMatch = pattern.match(/\*\*\/([^*]+)$/);
|
|
703
|
+
if (nameMatch && !nameMatch[1].startsWith("*.")) {
|
|
704
|
+
allowedExactNames.add(nameMatch[1]);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const ignoreDirs = /* @__PURE__ */ new Set();
|
|
708
|
+
for (const pattern of ignorePatterns) {
|
|
709
|
+
const dirMatch = pattern.match(/\*\*\/([^/*]+)\/\*\*$/);
|
|
710
|
+
if (dirMatch) {
|
|
711
|
+
ignoreDirs.add(dirMatch[1]);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const ignoreFiles = /* @__PURE__ */ new Set();
|
|
715
|
+
for (const pattern of ignorePatterns) {
|
|
716
|
+
const fileMatch = pattern.match(/\*\*\/\*(\.\w+)$/);
|
|
717
|
+
if (fileMatch) {
|
|
718
|
+
ignoreFiles.add(fileMatch[1]);
|
|
719
|
+
}
|
|
720
|
+
const exactMatch = pattern.match(/\*\*\/([^*]+)$/);
|
|
721
|
+
if (exactMatch && !exactMatch[1].includes("/")) {
|
|
722
|
+
ignoreFiles.add(exactMatch[1]);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const walk = (dir) => {
|
|
726
|
+
let entries;
|
|
727
|
+
try {
|
|
728
|
+
entries = readdirSync(dir);
|
|
729
|
+
} catch {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
for (const entry of entries) {
|
|
733
|
+
const fullPath = join(dir, entry);
|
|
734
|
+
let stat;
|
|
735
|
+
try {
|
|
736
|
+
stat = statSync(fullPath);
|
|
737
|
+
} catch {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (stat.isDirectory()) {
|
|
741
|
+
if (ignoreDirs.has(entry)) continue;
|
|
742
|
+
if (entry.startsWith(".")) continue;
|
|
743
|
+
walk(fullPath);
|
|
744
|
+
} else if (stat.isFile()) {
|
|
745
|
+
const ext = extname(entry);
|
|
746
|
+
if (ignoreFiles.has(ext)) continue;
|
|
747
|
+
if (ignoreFiles.has(entry)) continue;
|
|
748
|
+
if (allowedExtensions.has(ext) || allowedExactNames.has(entry)) {
|
|
749
|
+
results.push(fullPath);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
walk(rootPath);
|
|
755
|
+
return results;
|
|
756
|
+
}
|
|
757
|
+
// ── Cleanup ──
|
|
758
|
+
close() {
|
|
759
|
+
this.sqliteDb?.close();
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
function resolveConfig(overrides) {
|
|
763
|
+
const openclawHome = process.env.OPENCLAW_HOME || join(process.env.HOME || "/Users/lesa", ".openclaw");
|
|
764
|
+
const dataDir = overrides?.dataDir || join(openclawHome, "memory-crystal");
|
|
765
|
+
loadEnvFile(join(dataDir, ".env"));
|
|
766
|
+
const openaiApiKey = overrides?.openaiApiKey || process.env.OPENAI_API_KEY || opRead(openclawHome, "OpenAI API", "api key");
|
|
767
|
+
const googleApiKey = overrides?.googleApiKey || process.env.GOOGLE_API_KEY || opRead(openclawHome, "Google AI", "api key");
|
|
768
|
+
const remoteToken = overrides?.remoteToken || process.env.CRYSTAL_REMOTE_TOKEN || opRead(openclawHome, "Memory Crystal Remote", "token");
|
|
769
|
+
return {
|
|
770
|
+
dataDir,
|
|
771
|
+
embeddingProvider: overrides?.embeddingProvider || process.env.CRYSTAL_EMBEDDING_PROVIDER || "openai",
|
|
772
|
+
openaiApiKey,
|
|
773
|
+
openaiModel: overrides?.openaiModel || process.env.CRYSTAL_OPENAI_MODEL || "text-embedding-3-small",
|
|
774
|
+
ollamaHost: overrides?.ollamaHost || process.env.CRYSTAL_OLLAMA_HOST || "http://localhost:11434",
|
|
775
|
+
ollamaModel: overrides?.ollamaModel || process.env.CRYSTAL_OLLAMA_MODEL || "nomic-embed-text",
|
|
776
|
+
googleApiKey,
|
|
777
|
+
googleModel: overrides?.googleModel || process.env.CRYSTAL_GOOGLE_MODEL || "text-embedding-004",
|
|
778
|
+
remoteUrl: overrides?.remoteUrl || process.env.CRYSTAL_REMOTE_URL,
|
|
779
|
+
remoteToken
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function loadEnvFile(path) {
|
|
783
|
+
if (!existsSync(path)) return;
|
|
784
|
+
const content = readFileSync(path, "utf8");
|
|
785
|
+
for (const line of content.split("\n")) {
|
|
786
|
+
const trimmed = line.trim();
|
|
787
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
788
|
+
const eqIdx = trimmed.indexOf("=");
|
|
789
|
+
if (eqIdx === -1) continue;
|
|
790
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
791
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
792
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
793
|
+
value = value.slice(1, -1);
|
|
794
|
+
}
|
|
795
|
+
if (key && !process.env[key]) {
|
|
796
|
+
process.env[key] = value;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function opRead(openclawHome, item, field) {
|
|
801
|
+
try {
|
|
802
|
+
const saTokenPath = join(openclawHome, "secrets", "op-sa-token");
|
|
803
|
+
if (!existsSync(saTokenPath)) return void 0;
|
|
804
|
+
const saToken = readFileSync(saTokenPath, "utf8").trim();
|
|
805
|
+
return execSync(`op read "op://Agent Secrets/${item}/${field}" 2>/dev/null`, {
|
|
806
|
+
encoding: "utf8",
|
|
807
|
+
env: { ...process.env, OP_SERVICE_ACCOUNT_TOKEN: saToken },
|
|
808
|
+
timeout: 1e4
|
|
809
|
+
}).trim() || void 0;
|
|
810
|
+
} catch {
|
|
811
|
+
return void 0;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export {
|
|
816
|
+
Crystal,
|
|
817
|
+
resolveConfig
|
|
818
|
+
};
|