@vefimov/crew 0.0.0-stage → 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/dist/crew.js +1854 -0
- package/dist/web/assets/index-5zQyoY2M.js +37 -0
- package/dist/web/assets/index-Da4GnxKq.css +2 -0
- package/dist/web/index.html +15 -0
- package/dist/web/logo.svg +12 -0
- package/package.json +41 -4
- package/README.md +0 -3
package/dist/crew.js
ADDED
|
@@ -0,0 +1,1854 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// ../packages/core/src/crew.ts
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
|
|
9
|
+
// ../packages/memory/src/store.ts
|
|
10
|
+
import { DatabaseSync } from "node:sqlite";
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
// ../packages/memory/src/migrations.ts
|
|
16
|
+
var migrations = [
|
|
17
|
+
/* 1 — initial schema */
|
|
18
|
+
`
|
|
19
|
+
CREATE TABLE agents (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
22
|
+
role TEXT NOT NULL DEFAULT '',
|
|
23
|
+
personality TEXT NOT NULL DEFAULT '',
|
|
24
|
+
system_prompt TEXT NOT NULL DEFAULT '',
|
|
25
|
+
model TEXT,
|
|
26
|
+
avatar TEXT NOT NULL DEFAULT '\u{1F916}',
|
|
27
|
+
color TEXT NOT NULL DEFAULT '#7c5cff',
|
|
28
|
+
created_at INTEGER NOT NULL,
|
|
29
|
+
updated_at INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE conversations (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
35
|
+
channel TEXT NOT NULL,
|
|
36
|
+
channel_ref TEXT NOT NULL,
|
|
37
|
+
created_at INTEGER NOT NULL,
|
|
38
|
+
UNIQUE (agent_id, channel, channel_ref)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE messages (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
44
|
+
role TEXT NOT NULL,
|
|
45
|
+
content TEXT NOT NULL,
|
|
46
|
+
meta TEXT,
|
|
47
|
+
created_at INTEGER NOT NULL
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX messages_conv ON messages(conversation_id, created_at);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE memories (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
54
|
+
content TEXT NOT NULL,
|
|
55
|
+
type TEXT NOT NULL DEFAULT 'fact',
|
|
56
|
+
importance REAL NOT NULL DEFAULT 0.5,
|
|
57
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
58
|
+
embedding BLOB,
|
|
59
|
+
created_at INTEGER NOT NULL,
|
|
60
|
+
last_used_at INTEGER
|
|
61
|
+
);
|
|
62
|
+
CREATE INDEX memories_agent ON memories(agent_id);
|
|
63
|
+
|
|
64
|
+
CREATE VIRTUAL TABLE memories_fts USING fts5(content, content='memories', content_rowid='rowid');
|
|
65
|
+
CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
|
|
66
|
+
INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
67
|
+
END;
|
|
68
|
+
CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
|
|
69
|
+
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
|
70
|
+
END;
|
|
71
|
+
CREATE TRIGGER memories_au AFTER UPDATE OF content ON memories BEGIN
|
|
72
|
+
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
|
73
|
+
INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
74
|
+
END;
|
|
75
|
+
|
|
76
|
+
CREATE TABLE files_access (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
79
|
+
path TEXT NOT NULL,
|
|
80
|
+
mode TEXT NOT NULL DEFAULT 'ro',
|
|
81
|
+
UNIQUE (agent_id, path)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE settings (
|
|
85
|
+
key TEXT PRIMARY KEY,
|
|
86
|
+
value TEXT NOT NULL
|
|
87
|
+
);
|
|
88
|
+
`,
|
|
89
|
+
/* 2 — background tasks */
|
|
90
|
+
`
|
|
91
|
+
CREATE TABLE tasks (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
kind TEXT NOT NULL,
|
|
94
|
+
payload TEXT NOT NULL,
|
|
95
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
96
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
97
|
+
error TEXT,
|
|
98
|
+
run_at INTEGER NOT NULL,
|
|
99
|
+
created_at INTEGER NOT NULL,
|
|
100
|
+
updated_at INTEGER NOT NULL
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX tasks_queue ON tasks(status, run_at);
|
|
103
|
+
`
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
// ../packages/memory/src/vector.ts
|
|
107
|
+
function toBlob(vec) {
|
|
108
|
+
return new Uint8Array(new Float32Array(vec).buffer);
|
|
109
|
+
}
|
|
110
|
+
function fromBlob(blob) {
|
|
111
|
+
const copy = blob.slice();
|
|
112
|
+
return new Float32Array(copy.buffer, copy.byteOffset, copy.byteLength / 4);
|
|
113
|
+
}
|
|
114
|
+
function cosine(a, b) {
|
|
115
|
+
if (a.length !== b.length || a.length === 0) return 0;
|
|
116
|
+
let dot = 0;
|
|
117
|
+
let na = 0;
|
|
118
|
+
let nb = 0;
|
|
119
|
+
for (let i = 0; i < a.length; i++) {
|
|
120
|
+
dot += a[i] * b[i];
|
|
121
|
+
na += a[i] * a[i];
|
|
122
|
+
nb += b[i] * b[i];
|
|
123
|
+
}
|
|
124
|
+
if (na === 0 || nb === 0) return 0;
|
|
125
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
126
|
+
}
|
|
127
|
+
var STOP = new Set(
|
|
128
|
+
"the and for are was were you your what which who how why when where this that with from have has had use used does did not but can could would should about into our out his her its they them their there \u0447\u0442\u043E \u043A\u0430\u043A \u044D\u0442\u043E \u0433\u0434\u0435 \u043A\u043E\u0433\u0434\u0430 \u043A\u0442\u043E \u0434\u043B\u044F \u0438\u043B\u0438 \u0435\u0441\u043B\u0438 \u0442\u0430\u043A \u0443\u0436\u0435 \u0432\u0441\u0435 \u043E\u043D\u0430 \u043E\u043D\u0438 \u043E\u043D\u043E \u0435\u0433\u043E \u043C\u043D\u0435 \u043C\u0435\u043D\u044F \u043C\u043E\u0439 \u043C\u043E\u044F \u043C\u043E\u0438 \u043D\u0430\u0448 \u0432\u0430\u0448 \u0442\u0435\u0431\u0435 \u0442\u0435\u0431\u044F \u0449\u043E \u044F\u043A \u044D\u0442\u043E \u043A\u043E\u043B\u0438 \u0445\u0442\u043E \u0430\u0431\u043E \u044F\u043A\u0449\u043E \u0432\u0436\u0435 \u0432\u0441\u0456 \u0432\u043E\u043D\u0430 \u0432\u043E\u043D\u0438 \u0439\u043E\u0433\u043E \u043C\u0435\u043D\u0456 \u043C\u0435\u043D\u0435 \u043C\u0456\u0439 \u043C\u043E\u044F \u043C\u043E\u0457 \u043D\u0430\u0448 \u0432\u0430\u0448 \u0442\u043E\u0431\u0456 \u0442\u0435\u0431\u0435".split(" ")
|
|
129
|
+
);
|
|
130
|
+
function ftsQuery(text) {
|
|
131
|
+
const words = text.toLowerCase().match(/[\p{L}\p{N}_]{3,}/gu)?.filter((w) => !STOP.has(w)).slice(0, 24);
|
|
132
|
+
if (!words?.length) return null;
|
|
133
|
+
return [...new Set(words)].map((w) => w.length >= 5 ? `"${w.slice(0, Math.max(5, w.length - 2))}"*` : `"${w}"`).join(" OR ");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ../packages/memory/src/store.ts
|
|
137
|
+
var now = () => Date.now();
|
|
138
|
+
var id = () => randomUUID();
|
|
139
|
+
var AGENT_COLORS = ["#7c5cff", "#ff6b6b", "#20c997", "#fab005", "#4dabf7", "#e64980", "#82c91e", "#fd7e14"];
|
|
140
|
+
function toAgent(r) {
|
|
141
|
+
return {
|
|
142
|
+
id: r.id,
|
|
143
|
+
name: r.name,
|
|
144
|
+
role: r.role,
|
|
145
|
+
personality: r.personality,
|
|
146
|
+
systemPrompt: r.system_prompt,
|
|
147
|
+
model: r.model ?? null,
|
|
148
|
+
avatar: r.avatar,
|
|
149
|
+
color: r.color,
|
|
150
|
+
createdAt: Number(r.created_at),
|
|
151
|
+
updatedAt: Number(r.updated_at)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function toMemory(r) {
|
|
155
|
+
return {
|
|
156
|
+
id: r.id,
|
|
157
|
+
agentId: r.agent_id,
|
|
158
|
+
content: r.content,
|
|
159
|
+
type: r.type,
|
|
160
|
+
importance: Number(r.importance),
|
|
161
|
+
source: r.source,
|
|
162
|
+
createdAt: Number(r.created_at),
|
|
163
|
+
lastUsedAt: r.last_used_at == null ? null : Number(r.last_used_at)
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function toMessage(r) {
|
|
167
|
+
return {
|
|
168
|
+
id: r.id,
|
|
169
|
+
conversationId: r.conversation_id,
|
|
170
|
+
role: r.role,
|
|
171
|
+
content: r.content,
|
|
172
|
+
meta: r.meta ? JSON.parse(r.meta) : null,
|
|
173
|
+
createdAt: Number(r.created_at)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function toTask(r) {
|
|
177
|
+
return {
|
|
178
|
+
id: r.id,
|
|
179
|
+
kind: r.kind,
|
|
180
|
+
payload: JSON.parse(r.payload),
|
|
181
|
+
status: r.status,
|
|
182
|
+
attempts: Number(r.attempts),
|
|
183
|
+
error: r.error ?? null,
|
|
184
|
+
runAt: Number(r.run_at),
|
|
185
|
+
createdAt: Number(r.created_at),
|
|
186
|
+
updatedAt: Number(r.updated_at)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
var Store = class {
|
|
190
|
+
constructor(file) {
|
|
191
|
+
this.file = file;
|
|
192
|
+
if (file !== ":memory:") mkdirSync(dirname(file), { recursive: true });
|
|
193
|
+
this.db = new DatabaseSync(file);
|
|
194
|
+
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
|
|
195
|
+
this.migrate();
|
|
196
|
+
}
|
|
197
|
+
file;
|
|
198
|
+
db;
|
|
199
|
+
migrate() {
|
|
200
|
+
const { user_version } = this.db.prepare("PRAGMA user_version").get();
|
|
201
|
+
for (let v = user_version; v < migrations.length; v++) {
|
|
202
|
+
this.db.exec("BEGIN");
|
|
203
|
+
try {
|
|
204
|
+
this.db.exec(migrations[v]);
|
|
205
|
+
this.db.exec(`PRAGMA user_version = ${v + 1}`);
|
|
206
|
+
this.db.exec("COMMIT");
|
|
207
|
+
} catch (err) {
|
|
208
|
+
this.db.exec("ROLLBACK");
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
close() {
|
|
214
|
+
this.db.close();
|
|
215
|
+
}
|
|
216
|
+
// ── agents ────────────────────────────────────────────────────────────
|
|
217
|
+
listAgents() {
|
|
218
|
+
return this.db.prepare("SELECT * FROM agents ORDER BY created_at").all().map(toAgent);
|
|
219
|
+
}
|
|
220
|
+
getAgent(idOrName) {
|
|
221
|
+
const r = this.db.prepare("SELECT * FROM agents WHERE id = ? OR name = ? COLLATE NOCASE").get(idOrName, idOrName);
|
|
222
|
+
return r ? toAgent(r) : null;
|
|
223
|
+
}
|
|
224
|
+
createAgent(input) {
|
|
225
|
+
const count = this.db.prepare("SELECT COUNT(*) AS n FROM agents").get().n;
|
|
226
|
+
const t = now();
|
|
227
|
+
const agentId = id();
|
|
228
|
+
this.db.prepare(
|
|
229
|
+
`INSERT INTO agents (id, name, role, personality, system_prompt, model, avatar, color, created_at, updated_at)
|
|
230
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
231
|
+
).run(
|
|
232
|
+
agentId,
|
|
233
|
+
input.name.trim(),
|
|
234
|
+
input.role ?? "",
|
|
235
|
+
input.personality ?? "",
|
|
236
|
+
input.systemPrompt ?? "",
|
|
237
|
+
input.model ?? null,
|
|
238
|
+
input.avatar ?? "\u{1F916}",
|
|
239
|
+
input.color ?? AGENT_COLORS[count % AGENT_COLORS.length],
|
|
240
|
+
t,
|
|
241
|
+
t
|
|
242
|
+
);
|
|
243
|
+
return this.getAgent(agentId);
|
|
244
|
+
}
|
|
245
|
+
updateAgent(agentId, patch) {
|
|
246
|
+
const cur = this.getAgent(agentId);
|
|
247
|
+
if (!cur) throw new Error(`Agent not found: ${agentId}`);
|
|
248
|
+
const next = { ...cur, ...patch };
|
|
249
|
+
this.db.prepare(
|
|
250
|
+
`UPDATE agents SET name = ?, role = ?, personality = ?, system_prompt = ?, model = ?, avatar = ?, color = ?, updated_at = ?
|
|
251
|
+
WHERE id = ?`
|
|
252
|
+
).run(next.name, next.role, next.personality, next.systemPrompt, next.model ?? null, next.avatar, next.color, now(), cur.id);
|
|
253
|
+
return this.getAgent(cur.id);
|
|
254
|
+
}
|
|
255
|
+
deleteAgent(agentId) {
|
|
256
|
+
this.db.prepare("DELETE FROM agents WHERE id = ?").run(agentId);
|
|
257
|
+
}
|
|
258
|
+
// ── conversations & messages ─────────────────────────────────────────
|
|
259
|
+
conversation(agentId, channel = "web", channelRef = "default") {
|
|
260
|
+
const found = this.db.prepare("SELECT * FROM conversations WHERE agent_id = ? AND channel = ? AND channel_ref = ?").get(agentId, channel, channelRef);
|
|
261
|
+
if (found) {
|
|
262
|
+
return {
|
|
263
|
+
id: found.id,
|
|
264
|
+
agentId,
|
|
265
|
+
channel,
|
|
266
|
+
channelRef,
|
|
267
|
+
createdAt: Number(found.created_at)
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const conv = { id: id(), agentId, channel, channelRef, createdAt: now() };
|
|
271
|
+
this.db.prepare("INSERT INTO conversations (id, agent_id, channel, channel_ref, created_at) VALUES (?, ?, ?, ?, ?)").run(conv.id, agentId, channel, channelRef, conv.createdAt);
|
|
272
|
+
return conv;
|
|
273
|
+
}
|
|
274
|
+
listConversations(agentId) {
|
|
275
|
+
return this.db.prepare("SELECT * FROM conversations WHERE agent_id = ? ORDER BY created_at").all(agentId).map(
|
|
276
|
+
(r) => ({
|
|
277
|
+
id: r.id,
|
|
278
|
+
agentId,
|
|
279
|
+
channel: r.channel,
|
|
280
|
+
channelRef: r.channel_ref,
|
|
281
|
+
createdAt: Number(r.created_at)
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
addMessage(conversationId, role, content, meta) {
|
|
286
|
+
const msg = { id: id(), conversationId, role, content, meta: meta ?? null, createdAt: now() };
|
|
287
|
+
this.db.prepare("INSERT INTO messages (id, conversation_id, role, content, meta, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(msg.id, conversationId, role, content, meta ? JSON.stringify(meta) : null, msg.createdAt);
|
|
288
|
+
return msg;
|
|
289
|
+
}
|
|
290
|
+
/** Last `limit` messages of a conversation, oldest first. */
|
|
291
|
+
messages(conversationId, limit = 50) {
|
|
292
|
+
const rows = this.db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?").all(conversationId, limit);
|
|
293
|
+
return rows.reverse().map(toMessage);
|
|
294
|
+
}
|
|
295
|
+
clearConversation(conversationId) {
|
|
296
|
+
this.db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversationId);
|
|
297
|
+
}
|
|
298
|
+
// ── memories ─────────────────────────────────────────────────────────
|
|
299
|
+
addMemory(input) {
|
|
300
|
+
const memId = id();
|
|
301
|
+
this.db.prepare(
|
|
302
|
+
`INSERT INTO memories (id, agent_id, content, type, importance, source, embedding, created_at)
|
|
303
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
304
|
+
).run(
|
|
305
|
+
memId,
|
|
306
|
+
input.agentId,
|
|
307
|
+
input.content.trim(),
|
|
308
|
+
input.type ?? "fact",
|
|
309
|
+
Math.min(1, Math.max(0, input.importance ?? 0.5)),
|
|
310
|
+
input.source ?? "manual",
|
|
311
|
+
input.embedding?.length ? toBlob(input.embedding) : null,
|
|
312
|
+
now()
|
|
313
|
+
);
|
|
314
|
+
return this.getMemory(memId);
|
|
315
|
+
}
|
|
316
|
+
getMemory(memId) {
|
|
317
|
+
const r = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(memId);
|
|
318
|
+
return r ? toMemory(r) : null;
|
|
319
|
+
}
|
|
320
|
+
listMemories(agentId, limit = 200) {
|
|
321
|
+
return this.db.prepare("SELECT * FROM memories WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?").all(agentId, limit).map(toMemory);
|
|
322
|
+
}
|
|
323
|
+
countMemories(agentId) {
|
|
324
|
+
return this.db.prepare("SELECT COUNT(*) AS n FROM memories WHERE agent_id = ?").get(agentId).n;
|
|
325
|
+
}
|
|
326
|
+
deleteMemory(memId) {
|
|
327
|
+
this.db.prepare("DELETE FROM memories WHERE id = ?").run(memId);
|
|
328
|
+
}
|
|
329
|
+
setMemoryEmbedding(memId, embedding) {
|
|
330
|
+
this.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(toBlob(embedding), memId);
|
|
331
|
+
}
|
|
332
|
+
/** Memories that still lack an embedding (e.g. created while the embed model was offline). */
|
|
333
|
+
memoriesWithoutEmbedding(limit = 50) {
|
|
334
|
+
return this.db.prepare("SELECT * FROM memories WHERE embedding IS NULL LIMIT ?").all(limit).map(toMemory);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Hybrid recall: full-text (BM25) and vector similarity are merged with
|
|
338
|
+
* reciprocal-rank fusion, then nudged by importance and recency.
|
|
339
|
+
*/
|
|
340
|
+
searchMemories(agentId, query, opts = {}) {
|
|
341
|
+
const limit = opts.limit ?? 6;
|
|
342
|
+
const ranks = /* @__PURE__ */ new Map();
|
|
343
|
+
const rows = /* @__PURE__ */ new Map();
|
|
344
|
+
const K = 60;
|
|
345
|
+
const fts = ftsQuery(query);
|
|
346
|
+
if (fts) {
|
|
347
|
+
const hits = this.db.prepare(
|
|
348
|
+
`SELECT m.* FROM memories_fts f JOIN memories m ON m.rowid = f.rowid
|
|
349
|
+
WHERE memories_fts MATCH ? AND m.agent_id = ? ORDER BY bm25(memories_fts) LIMIT 30`
|
|
350
|
+
).all(fts, agentId);
|
|
351
|
+
hits.forEach((r, i) => {
|
|
352
|
+
rows.set(r.id, r);
|
|
353
|
+
ranks.set(r.id, (ranks.get(r.id) ?? 0) + 1 / (K + i));
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (opts.embedding?.length) {
|
|
357
|
+
const all = this.db.prepare("SELECT * FROM memories WHERE agent_id = ? AND embedding IS NOT NULL").all(agentId);
|
|
358
|
+
all.map((r) => ({ r, s: cosine(opts.embedding, fromBlob(r.embedding)) })).filter((x) => x.s > 0.35).sort((a, b) => b.s - a.s).slice(0, 30).forEach(({ r }, i) => {
|
|
359
|
+
rows.set(r.id, r);
|
|
360
|
+
ranks.set(r.id, (ranks.get(r.id) ?? 0) + 1 / (K + i));
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const t = now();
|
|
364
|
+
const scored = [...ranks.entries()].map(([memId, rrf]) => {
|
|
365
|
+
const m = toMemory(rows.get(memId));
|
|
366
|
+
const ageDays = (t - m.createdAt) / 864e5;
|
|
367
|
+
const score = rrf * (1 + m.importance) * (1 + 0.25 * Math.exp(-ageDays / 30));
|
|
368
|
+
return { ...m, score };
|
|
369
|
+
});
|
|
370
|
+
scored.sort((a, b) => b.score - a.score);
|
|
371
|
+
const top = scored.slice(0, limit);
|
|
372
|
+
if (top.length) {
|
|
373
|
+
const touch = this.db.prepare("UPDATE memories SET last_used_at = ? WHERE id = ?");
|
|
374
|
+
for (const m of top) touch.run(t, m.id);
|
|
375
|
+
}
|
|
376
|
+
return top;
|
|
377
|
+
}
|
|
378
|
+
/** The single most similar memory by embedding, used to avoid storing near-duplicates. */
|
|
379
|
+
nearest(agentId, embedding) {
|
|
380
|
+
let best = null;
|
|
381
|
+
const rows = this.db.prepare("SELECT * FROM memories WHERE agent_id = ? AND embedding IS NOT NULL").all(agentId);
|
|
382
|
+
for (const row of rows) {
|
|
383
|
+
const similarity = cosine(embedding, fromBlob(row.embedding));
|
|
384
|
+
if (!best || similarity > best.similarity) best = { row, similarity };
|
|
385
|
+
}
|
|
386
|
+
return best ? { memory: toMemory(best.row), similarity: best.similarity } : null;
|
|
387
|
+
}
|
|
388
|
+
/** The most important memories — always included in the agent's context. */
|
|
389
|
+
coreMemories(agentId, limit = 5) {
|
|
390
|
+
return this.db.prepare("SELECT * FROM memories WHERE agent_id = ? AND importance >= 0.8 ORDER BY importance DESC, created_at DESC LIMIT ?").all(agentId, limit).map(toMemory);
|
|
391
|
+
}
|
|
392
|
+
// ── folders ──────────────────────────────────────────────────────────
|
|
393
|
+
listFolders(agentId) {
|
|
394
|
+
return this.db.prepare("SELECT * FROM files_access WHERE agent_id = ? ORDER BY path").all(agentId).map((r) => ({
|
|
395
|
+
id: r.id,
|
|
396
|
+
agentId,
|
|
397
|
+
path: r.path,
|
|
398
|
+
mode: r.mode
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
grantFolder(agentId, path, mode = "ro") {
|
|
402
|
+
this.db.prepare(
|
|
403
|
+
`INSERT INTO files_access (id, agent_id, path, mode) VALUES (?, ?, ?, ?)
|
|
404
|
+
ON CONFLICT (agent_id, path) DO UPDATE SET mode = excluded.mode`
|
|
405
|
+
).run(id(), agentId, path, mode);
|
|
406
|
+
return this.listFolders(agentId).find((f) => f.path === path);
|
|
407
|
+
}
|
|
408
|
+
revokeFolder(folderId) {
|
|
409
|
+
this.db.prepare("DELETE FROM files_access WHERE id = ?").run(folderId);
|
|
410
|
+
}
|
|
411
|
+
// ── settings ─────────────────────────────────────────────────────────
|
|
412
|
+
getSetting(key) {
|
|
413
|
+
const r = this.db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
|
414
|
+
return r ? JSON.parse(r.value) : null;
|
|
415
|
+
}
|
|
416
|
+
setSetting(key, value) {
|
|
417
|
+
this.db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(key, JSON.stringify(value));
|
|
418
|
+
}
|
|
419
|
+
allSettings() {
|
|
420
|
+
const out = {};
|
|
421
|
+
for (const r of this.db.prepare("SELECT key, value FROM settings").all()) {
|
|
422
|
+
out[r.key] = JSON.parse(r.value);
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
// ── tasks (a tiny SQLite-backed queue) ────────────────────────────────
|
|
427
|
+
enqueue(kind, payload, delayMs = 0) {
|
|
428
|
+
const t = now();
|
|
429
|
+
const taskId = id();
|
|
430
|
+
this.db.prepare(
|
|
431
|
+
"INSERT INTO tasks (id, kind, payload, status, run_at, created_at, updated_at) VALUES (?, ?, ?, 'queued', ?, ?, ?)"
|
|
432
|
+
).run(taskId, kind, JSON.stringify(payload), t + delayMs, t, t);
|
|
433
|
+
return this.getTask(taskId);
|
|
434
|
+
}
|
|
435
|
+
getTask(taskId) {
|
|
436
|
+
const r = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(taskId);
|
|
437
|
+
return r ? toTask(r) : null;
|
|
438
|
+
}
|
|
439
|
+
/** Atomically claim the next due task. */
|
|
440
|
+
claimTask() {
|
|
441
|
+
const t = now();
|
|
442
|
+
const r = this.db.prepare(
|
|
443
|
+
`UPDATE tasks SET status = 'running', attempts = attempts + 1, updated_at = ?
|
|
444
|
+
WHERE id = (SELECT id FROM tasks WHERE status = 'queued' AND run_at <= ? ORDER BY run_at LIMIT 1)
|
|
445
|
+
RETURNING *`
|
|
446
|
+
).get(t, t);
|
|
447
|
+
return r ? toTask(r) : null;
|
|
448
|
+
}
|
|
449
|
+
finishTask(taskId, error, retryInMs) {
|
|
450
|
+
const t = now();
|
|
451
|
+
if (!error) {
|
|
452
|
+
this.db.prepare("UPDATE tasks SET status = 'done', error = NULL, updated_at = ? WHERE id = ?").run(t, taskId);
|
|
453
|
+
} else if (retryInMs != null) {
|
|
454
|
+
this.db.prepare("UPDATE tasks SET status = 'queued', error = ?, run_at = ?, updated_at = ? WHERE id = ?").run(error, t + retryInMs, t, taskId);
|
|
455
|
+
} else {
|
|
456
|
+
this.db.prepare("UPDATE tasks SET status = 'failed', error = ?, updated_at = ? WHERE id = ?").run(error, t, taskId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
listTasks(limit = 50) {
|
|
460
|
+
return this.db.prepare("SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?").all(limit).map(toTask);
|
|
461
|
+
}
|
|
462
|
+
/** Re-queue tasks left in `running` by a crashed process. */
|
|
463
|
+
recoverTasks() {
|
|
464
|
+
this.db.prepare("UPDATE tasks SET status = 'queued' WHERE status = 'running'").run();
|
|
465
|
+
}
|
|
466
|
+
pruneTasks(olderThanMs = 7 * 864e5) {
|
|
467
|
+
this.db.prepare("DELETE FROM tasks WHERE status IN ('done', 'failed') AND updated_at < ?").run(now() - olderThanMs);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// ../packages/core/src/config.ts
|
|
472
|
+
import { homedir } from "node:os";
|
|
473
|
+
import { join, resolve } from "node:path";
|
|
474
|
+
var DEFAULT_MODEL = "qwen2.5:3b";
|
|
475
|
+
var DEFAULT_EMBED_MODEL = "nomic-embed-text";
|
|
476
|
+
function expand(p) {
|
|
477
|
+
return p.startsWith("~") ? join(homedir(), p.slice(1)) : resolve(p);
|
|
478
|
+
}
|
|
479
|
+
function envConfig(env = process.env) {
|
|
480
|
+
const home = expand(env.CREW_HOME || "~/.crew");
|
|
481
|
+
return {
|
|
482
|
+
home,
|
|
483
|
+
dbFile: join(home, "crew.db"),
|
|
484
|
+
port: Number(env.CREW_PORT || env.PORT || 3e3),
|
|
485
|
+
provider: env.CREW_PROVIDER === "openai" ? "openai" : "ollama",
|
|
486
|
+
ollamaHost: env.OLLAMA_HOST ? normalizeHost(env.OLLAMA_HOST) : "http://127.0.0.1:11434",
|
|
487
|
+
openaiBaseUrl: env.OPENAI_BASE_URL || "https://api.openai.com/v1",
|
|
488
|
+
openaiApiKey: env.OPENAI_API_KEY || "",
|
|
489
|
+
model: env.CREW_MODEL || "",
|
|
490
|
+
embedModel: env.CREW_EMBED_MODEL || "",
|
|
491
|
+
telegramToken: env.CREW_TELEGRAM_TOKEN || ""
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function normalizeHost(h) {
|
|
495
|
+
let url = /^https?:\/\//.test(h) ? h : `http://${h}`;
|
|
496
|
+
url = url.replace("://0.0.0.0", "://127.0.0.1");
|
|
497
|
+
return url;
|
|
498
|
+
}
|
|
499
|
+
var SETTING_KEYS = [
|
|
500
|
+
"provider",
|
|
501
|
+
"ollamaHost",
|
|
502
|
+
"openaiBaseUrl",
|
|
503
|
+
"openaiApiKey",
|
|
504
|
+
"model",
|
|
505
|
+
"embedModel",
|
|
506
|
+
"telegramToken"
|
|
507
|
+
];
|
|
508
|
+
function resolveConfig(store, env = process.env) {
|
|
509
|
+
const base = envConfig(env);
|
|
510
|
+
const s = store.allSettings();
|
|
511
|
+
return {
|
|
512
|
+
...base,
|
|
513
|
+
provider: env.CREW_PROVIDER ? base.provider : s.provider === "openai" ? "openai" : "ollama",
|
|
514
|
+
ollamaHost: env.OLLAMA_HOST ? base.ollamaHost : s.ollamaHost || base.ollamaHost,
|
|
515
|
+
openaiBaseUrl: env.OPENAI_BASE_URL ? base.openaiBaseUrl : s.openaiBaseUrl || base.openaiBaseUrl,
|
|
516
|
+
openaiApiKey: base.openaiApiKey || s.openaiApiKey || "",
|
|
517
|
+
model: base.model || s.model || DEFAULT_MODEL,
|
|
518
|
+
embedModel: base.embedModel || s.embedModel || DEFAULT_EMBED_MODEL,
|
|
519
|
+
telegramToken: base.telegramToken || s.telegramToken || ""
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ../packages/core/src/llm/ollama.ts
|
|
524
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
525
|
+
|
|
526
|
+
// ../packages/core/src/llm/ndjson.ts
|
|
527
|
+
async function* lines(body) {
|
|
528
|
+
const decoder = new TextDecoder();
|
|
529
|
+
let buf = "";
|
|
530
|
+
for await (const chunk2 of body) {
|
|
531
|
+
buf += decoder.decode(chunk2, { stream: true });
|
|
532
|
+
let nl;
|
|
533
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
534
|
+
const line = buf.slice(0, nl).trim();
|
|
535
|
+
buf = buf.slice(nl + 1);
|
|
536
|
+
if (line) yield line;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (buf.trim()) yield buf.trim();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ../packages/core/src/llm/ollama.ts
|
|
543
|
+
function toOllama(m) {
|
|
544
|
+
switch (m.role) {
|
|
545
|
+
case "assistant":
|
|
546
|
+
return {
|
|
547
|
+
role: "assistant",
|
|
548
|
+
content: m.content,
|
|
549
|
+
...m.toolCalls?.length ? { tool_calls: m.toolCalls.map((c) => ({ function: { name: c.name, arguments: c.arguments } })) } : {}
|
|
550
|
+
};
|
|
551
|
+
case "tool":
|
|
552
|
+
return { role: "tool", content: m.content, tool_name: m.name };
|
|
553
|
+
default:
|
|
554
|
+
return { role: m.role, content: m.content };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
var OllamaProvider = class {
|
|
558
|
+
name = "ollama";
|
|
559
|
+
host;
|
|
560
|
+
constructor(host = "http://127.0.0.1:11434") {
|
|
561
|
+
this.host = host.replace(/\/+$/, "");
|
|
562
|
+
}
|
|
563
|
+
async chat(req) {
|
|
564
|
+
const res = await fetch(`${this.host}/api/chat`, {
|
|
565
|
+
method: "POST",
|
|
566
|
+
signal: req.signal,
|
|
567
|
+
headers: { "content-type": "application/json" },
|
|
568
|
+
body: JSON.stringify({
|
|
569
|
+
model: req.model,
|
|
570
|
+
messages: req.messages.map(toOllama),
|
|
571
|
+
tools: req.tools?.length ? req.tools.map((t) => ({ type: "function", function: t })) : void 0,
|
|
572
|
+
stream: true,
|
|
573
|
+
format: req.json ? "json" : void 0,
|
|
574
|
+
options: { temperature: req.temperature ?? 0.7 }
|
|
575
|
+
})
|
|
576
|
+
});
|
|
577
|
+
if (!res.ok || !res.body) {
|
|
578
|
+
const text = await res.text().catch(() => "");
|
|
579
|
+
throw new Error(`Ollama ${res.status}: ${text || res.statusText}`);
|
|
580
|
+
}
|
|
581
|
+
let content = "";
|
|
582
|
+
const toolCalls = [];
|
|
583
|
+
for await (const line of lines(res.body)) {
|
|
584
|
+
const chunk2 = JSON.parse(line);
|
|
585
|
+
if (chunk2.error) throw new Error(`Ollama: ${chunk2.error}`);
|
|
586
|
+
const delta = chunk2.message?.content ?? "";
|
|
587
|
+
if (delta) {
|
|
588
|
+
content += delta;
|
|
589
|
+
req.onToken?.(delta);
|
|
590
|
+
}
|
|
591
|
+
for (const c of chunk2.message?.tool_calls ?? []) {
|
|
592
|
+
toolCalls.push({ id: randomUUID2(), name: c.function.name, arguments: c.function.arguments ?? {} });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { content, toolCalls };
|
|
596
|
+
}
|
|
597
|
+
async embed(model, input) {
|
|
598
|
+
const res = await fetch(`${this.host}/api/embed`, {
|
|
599
|
+
method: "POST",
|
|
600
|
+
headers: { "content-type": "application/json" },
|
|
601
|
+
body: JSON.stringify({ model, input })
|
|
602
|
+
});
|
|
603
|
+
if (!res.ok) throw new Error(`Ollama embed ${res.status}: ${await res.text()}`);
|
|
604
|
+
const data = await res.json();
|
|
605
|
+
return data.embeddings;
|
|
606
|
+
}
|
|
607
|
+
async models() {
|
|
608
|
+
const res = await fetch(`${this.host}/api/tags`);
|
|
609
|
+
if (!res.ok) throw new Error(`Ollama ${res.status}`);
|
|
610
|
+
const data = await res.json();
|
|
611
|
+
return data.models.map((m) => ({ name: m.name, size: m.size, family: m.details?.family }));
|
|
612
|
+
}
|
|
613
|
+
async ping() {
|
|
614
|
+
try {
|
|
615
|
+
const res = await fetch(`${this.host}/api/version`, { signal: AbortSignal.timeout(1500) });
|
|
616
|
+
return res.ok;
|
|
617
|
+
} catch {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// ../packages/core/src/llm/openai.ts
|
|
624
|
+
function toOpenAI(m) {
|
|
625
|
+
switch (m.role) {
|
|
626
|
+
case "assistant":
|
|
627
|
+
return {
|
|
628
|
+
role: "assistant",
|
|
629
|
+
content: m.content || null,
|
|
630
|
+
...m.toolCalls?.length ? {
|
|
631
|
+
tool_calls: m.toolCalls.map((c) => ({
|
|
632
|
+
id: c.id,
|
|
633
|
+
type: "function",
|
|
634
|
+
function: { name: c.name, arguments: JSON.stringify(c.arguments) }
|
|
635
|
+
}))
|
|
636
|
+
} : {}
|
|
637
|
+
};
|
|
638
|
+
case "tool":
|
|
639
|
+
return { role: "tool", content: m.content, tool_call_id: m.toolCallId };
|
|
640
|
+
default:
|
|
641
|
+
return { role: m.role, content: m.content };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
var OpenAIProvider = class {
|
|
645
|
+
constructor(baseUrl = "http://127.0.0.1:1234/v1", apiKey = "") {
|
|
646
|
+
this.apiKey = apiKey;
|
|
647
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
648
|
+
}
|
|
649
|
+
apiKey;
|
|
650
|
+
name = "openai";
|
|
651
|
+
baseUrl;
|
|
652
|
+
headers() {
|
|
653
|
+
return {
|
|
654
|
+
"content-type": "application/json",
|
|
655
|
+
...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
async chat(req) {
|
|
659
|
+
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
signal: req.signal,
|
|
662
|
+
headers: this.headers(),
|
|
663
|
+
body: JSON.stringify({
|
|
664
|
+
model: req.model,
|
|
665
|
+
messages: req.messages.map(toOpenAI),
|
|
666
|
+
tools: req.tools?.length ? req.tools.map((t) => ({ type: "function", function: t })) : void 0,
|
|
667
|
+
stream: true,
|
|
668
|
+
temperature: req.temperature ?? 0.7,
|
|
669
|
+
response_format: req.json ? { type: "json_object" } : void 0
|
|
670
|
+
})
|
|
671
|
+
});
|
|
672
|
+
if (!res.ok || !res.body) throw new Error(`OpenAI-compatible ${res.status}: ${await res.text().catch(() => "")}`);
|
|
673
|
+
let content = "";
|
|
674
|
+
const partial = /* @__PURE__ */ new Map();
|
|
675
|
+
for await (const line of lines(res.body)) {
|
|
676
|
+
if (!line.startsWith("data:")) continue;
|
|
677
|
+
const data = line.slice(5).trim();
|
|
678
|
+
if (data === "[DONE]") break;
|
|
679
|
+
const delta = JSON.parse(data).choices?.[0]?.delta ?? {};
|
|
680
|
+
if (delta.content) {
|
|
681
|
+
content += delta.content;
|
|
682
|
+
req.onToken?.(delta.content);
|
|
683
|
+
}
|
|
684
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
685
|
+
const cur = partial.get(tc.index) ?? { id: tc.id ?? `call_${tc.index}`, name: "", args: "" };
|
|
686
|
+
if (tc.function?.name) cur.name += tc.function.name;
|
|
687
|
+
if (tc.function?.arguments) cur.args += tc.function.arguments;
|
|
688
|
+
partial.set(tc.index, cur);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const toolCalls = [...partial.values()].map((p) => ({
|
|
692
|
+
id: p.id,
|
|
693
|
+
name: p.name,
|
|
694
|
+
arguments: p.args ? safeJson(p.args) : {}
|
|
695
|
+
}));
|
|
696
|
+
return { content, toolCalls };
|
|
697
|
+
}
|
|
698
|
+
async embed(model, input) {
|
|
699
|
+
const res = await fetch(`${this.baseUrl}/embeddings`, {
|
|
700
|
+
method: "POST",
|
|
701
|
+
headers: this.headers(),
|
|
702
|
+
body: JSON.stringify({ model, input })
|
|
703
|
+
});
|
|
704
|
+
if (!res.ok) throw new Error(`Embeddings ${res.status}: ${await res.text()}`);
|
|
705
|
+
const data = await res.json();
|
|
706
|
+
return data.data.map((d) => d.embedding);
|
|
707
|
+
}
|
|
708
|
+
async models() {
|
|
709
|
+
const res = await fetch(`${this.baseUrl}/models`, { headers: this.headers() });
|
|
710
|
+
if (!res.ok) throw new Error(`Models ${res.status}`);
|
|
711
|
+
const data = await res.json();
|
|
712
|
+
return data.data.map((m) => ({ name: m.id }));
|
|
713
|
+
}
|
|
714
|
+
async ping() {
|
|
715
|
+
try {
|
|
716
|
+
const res = await fetch(`${this.baseUrl}/models`, { headers: this.headers(), signal: AbortSignal.timeout(1500) });
|
|
717
|
+
return res.ok;
|
|
718
|
+
} catch {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
function safeJson(s) {
|
|
724
|
+
try {
|
|
725
|
+
return JSON.parse(s);
|
|
726
|
+
} catch {
|
|
727
|
+
return {};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ../packages/core/src/prompt.ts
|
|
732
|
+
function buildSystemPrompt({ agent: agent2, memories, folders, channel, now: now2 = /* @__PURE__ */ new Date() }) {
|
|
733
|
+
const parts = [];
|
|
734
|
+
parts.push(
|
|
735
|
+
`You are ${agent2.name}${agent2.role ? `, ${agent2.role}` : ""}. You are a persistent AI colleague living in Crew \u2014 a local-first workspace. You remember previous conversations with the user through your long-term memory.`
|
|
736
|
+
);
|
|
737
|
+
if (agent2.personality) parts.push(`Your personality: ${agent2.personality}`);
|
|
738
|
+
if (agent2.systemPrompt) parts.push(agent2.systemPrompt);
|
|
739
|
+
parts.push(
|
|
740
|
+
[
|
|
741
|
+
"Guidelines:",
|
|
742
|
+
"- Reply in the same language the user writes in.",
|
|
743
|
+
"- Be concise and practical; use Markdown when it helps.",
|
|
744
|
+
"- Stay in character, but never invent facts about the user \u2014 if you don't know, ask.",
|
|
745
|
+
"- When the user tells you something worth keeping (a preference, decision, fact about them or the project), call the `remember` tool.",
|
|
746
|
+
channel === "telegram" ? "- You are chatting via Telegram: keep messages short." : ""
|
|
747
|
+
].filter(Boolean).join("\n")
|
|
748
|
+
);
|
|
749
|
+
if (memories.length) {
|
|
750
|
+
parts.push(
|
|
751
|
+
"What you remember (most relevant first):\n" + memories.map((m) => `- [${m.type}] ${m.content}`).join("\n")
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
if (folders.length) {
|
|
755
|
+
parts.push(
|
|
756
|
+
"Folders shared with you (use list_files / read_file / write_file):\n" + folders.map((f) => `- ${f.path} (${f.mode === "rw" ? "read-write" : "read-only"})`).join("\n")
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
parts.push(`Current date and time: ${now2.toISOString().slice(0, 16).replace("T", " ")} UTC.`);
|
|
760
|
+
return parts.join("\n\n");
|
|
761
|
+
}
|
|
762
|
+
var EXTRACT_PROMPT = `You maintain the long-term memory of an AI assistant.
|
|
763
|
+
Read the latest exchange and extract durable information worth remembering for weeks:
|
|
764
|
+
facts about the user, their projects, decisions that were made, and preferences.
|
|
765
|
+
Ignore small talk, questions, and anything already known.
|
|
766
|
+
|
|
767
|
+
Reply with JSON only: {"memories":[{"content":"...","type":"fact|decision|preference","importance":0.0-1.0}]}
|
|
768
|
+
Each content must be a short, self-contained sentence in the user's language. Return {"memories":[]} if nothing is worth saving.`;
|
|
769
|
+
|
|
770
|
+
// ../packages/core/src/tools/files.ts
|
|
771
|
+
import { mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
772
|
+
import { dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
|
|
773
|
+
var MAX_READ = 64 * 1024;
|
|
774
|
+
var AccessError = class extends Error {
|
|
775
|
+
};
|
|
776
|
+
async function realish(p) {
|
|
777
|
+
let cur = p;
|
|
778
|
+
for (; ; ) {
|
|
779
|
+
try {
|
|
780
|
+
return join2(await realpath(cur), relative(cur, p));
|
|
781
|
+
} catch {
|
|
782
|
+
const parent = dirname2(cur);
|
|
783
|
+
if (parent === cur) throw new AccessError(`Path does not exist: ${p}`);
|
|
784
|
+
cur = parent;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
function inside(child, parent) {
|
|
789
|
+
const rel = relative(parent, child);
|
|
790
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
791
|
+
}
|
|
792
|
+
async function resolveAllowed(folders, requested, need) {
|
|
793
|
+
if (!folders.length) throw new AccessError("No folders are shared with you. Ask the user to grant access in Crew.");
|
|
794
|
+
const raw = String(requested || ".");
|
|
795
|
+
const candidates = isAbsolute(raw) ? [resolve2(raw)] : folders.map((f) => resolve2(f.path, raw));
|
|
796
|
+
for (const target of candidates) {
|
|
797
|
+
const finalPath = await realish(target);
|
|
798
|
+
for (const folder of folders) {
|
|
799
|
+
const root = await realpath(folder.path).catch(() => null);
|
|
800
|
+
if (!root || !inside(finalPath, root)) continue;
|
|
801
|
+
if (need === "rw" && folder.mode !== "rw") {
|
|
802
|
+
throw new AccessError(`Folder ${folder.path} is read-only for you.`);
|
|
803
|
+
}
|
|
804
|
+
return { path: finalPath, folder };
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
throw new AccessError(`Access denied: ${raw} is outside of your shared folders.`);
|
|
808
|
+
}
|
|
809
|
+
var listFiles = {
|
|
810
|
+
name: "list_files",
|
|
811
|
+
description: "List files and folders in a directory you have access to.",
|
|
812
|
+
parameters: {
|
|
813
|
+
type: "object",
|
|
814
|
+
properties: { path: { type: "string", description: "Directory path. Relative paths resolve against your shared folders." } }
|
|
815
|
+
},
|
|
816
|
+
async run(args, { agent: agent2, store }) {
|
|
817
|
+
const { path } = await resolveAllowed(store.listFolders(agent2.id), String(args.path ?? "."), "ro");
|
|
818
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
819
|
+
const shown = entries.filter((e) => !e.name.startsWith(".") && e.name !== "node_modules").slice(0, 200).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
|
|
820
|
+
return `${path}
|
|
821
|
+
${shown.join("\n") || "(empty)"}`;
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
var readFileTool = {
|
|
825
|
+
name: "read_file",
|
|
826
|
+
description: "Read a text file you have access to.",
|
|
827
|
+
parameters: {
|
|
828
|
+
type: "object",
|
|
829
|
+
properties: { path: { type: "string" } },
|
|
830
|
+
required: ["path"]
|
|
831
|
+
},
|
|
832
|
+
async run(args, { agent: agent2, store }) {
|
|
833
|
+
const { path } = await resolveAllowed(store.listFolders(agent2.id), String(args.path), "ro");
|
|
834
|
+
const info = await stat(path);
|
|
835
|
+
if (info.isDirectory()) return `${path} is a directory \u2014 use list_files.`;
|
|
836
|
+
const buf = await readFile(path);
|
|
837
|
+
if (buf.subarray(0, 8e3).includes(0)) return `${path} looks like a binary file (${info.size} bytes).`;
|
|
838
|
+
const text = buf.subarray(0, MAX_READ).toString("utf8");
|
|
839
|
+
return info.size > MAX_READ ? `${text}
|
|
840
|
+
|
|
841
|
+
\u2026truncated (${info.size} bytes total)` : text;
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
var writeFileTool = {
|
|
845
|
+
name: "write_file",
|
|
846
|
+
description: "Create or overwrite a text file inside a folder shared with you in read-write mode.",
|
|
847
|
+
parameters: {
|
|
848
|
+
type: "object",
|
|
849
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
850
|
+
required: ["path", "content"]
|
|
851
|
+
},
|
|
852
|
+
async run(args, { agent: agent2, store }) {
|
|
853
|
+
const { path } = await resolveAllowed(store.listFolders(agent2.id), String(args.path), "rw");
|
|
854
|
+
await mkdir(dirname2(path), { recursive: true });
|
|
855
|
+
const content = String(args.content ?? "");
|
|
856
|
+
await writeFile(path, content);
|
|
857
|
+
return `Wrote ${Buffer.byteLength(content)} bytes to ${path}`;
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
// ../packages/core/src/tools/memory.ts
|
|
862
|
+
var rememberTool = {
|
|
863
|
+
name: "remember",
|
|
864
|
+
description: "Save an important long-term fact, decision or user preference so you remember it in future conversations. Use sparingly, only for durable information.",
|
|
865
|
+
parameters: {
|
|
866
|
+
type: "object",
|
|
867
|
+
properties: {
|
|
868
|
+
content: { type: "string", description: "A short self-contained statement, e.g. 'User's project uses Postgres 16'." },
|
|
869
|
+
type: { type: "string", enum: ["fact", "decision", "preference"] },
|
|
870
|
+
importance: { type: "number", description: "0..1, how important it is to remember" }
|
|
871
|
+
},
|
|
872
|
+
required: ["content"]
|
|
873
|
+
},
|
|
874
|
+
run: (args, ctx) => ctx.remember(String(args.content), args.type, args.importance)
|
|
875
|
+
};
|
|
876
|
+
var recallTool = {
|
|
877
|
+
name: "recall",
|
|
878
|
+
description: "Search your long-term memory for things you were told before.",
|
|
879
|
+
parameters: {
|
|
880
|
+
type: "object",
|
|
881
|
+
properties: { query: { type: "string" } },
|
|
882
|
+
required: ["query"]
|
|
883
|
+
},
|
|
884
|
+
run: (args, ctx) => ctx.recall(String(args.query))
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// ../packages/core/src/tools/index.ts
|
|
888
|
+
var memoryTools = [rememberTool, recallTool];
|
|
889
|
+
var fileTools = [listFiles, readFileTool, writeFileTool];
|
|
890
|
+
|
|
891
|
+
// ../packages/core/src/worker.ts
|
|
892
|
+
var Worker = class {
|
|
893
|
+
constructor(store, intervalMs = 750) {
|
|
894
|
+
this.store = store;
|
|
895
|
+
this.intervalMs = intervalMs;
|
|
896
|
+
}
|
|
897
|
+
store;
|
|
898
|
+
intervalMs;
|
|
899
|
+
handlers = /* @__PURE__ */ new Map();
|
|
900
|
+
timer = null;
|
|
901
|
+
running = null;
|
|
902
|
+
handle(kind, fn) {
|
|
903
|
+
this.handlers.set(kind, fn);
|
|
904
|
+
}
|
|
905
|
+
start() {
|
|
906
|
+
if (this.timer) return;
|
|
907
|
+
this.store.recoverTasks();
|
|
908
|
+
this.store.pruneTasks();
|
|
909
|
+
this.timer = setInterval(() => {
|
|
910
|
+
if (!this.running) this.running = this.drain().finally(() => this.running = null);
|
|
911
|
+
}, this.intervalMs);
|
|
912
|
+
this.timer.unref?.();
|
|
913
|
+
}
|
|
914
|
+
async stop() {
|
|
915
|
+
if (this.timer) clearInterval(this.timer);
|
|
916
|
+
this.timer = null;
|
|
917
|
+
await this.running;
|
|
918
|
+
}
|
|
919
|
+
/** Run every due task. Exposed for tests and the CLI. */
|
|
920
|
+
async drain() {
|
|
921
|
+
for (let task = this.store.claimTask(); task; task = this.store.claimTask()) {
|
|
922
|
+
const fn = this.handlers.get(task.kind);
|
|
923
|
+
if (!fn) {
|
|
924
|
+
this.store.finishTask(task.id, `No handler for ${task.kind}`);
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
try {
|
|
928
|
+
await fn(task.payload, task);
|
|
929
|
+
this.store.finishTask(task.id);
|
|
930
|
+
} catch (err) {
|
|
931
|
+
const msg = err.message;
|
|
932
|
+
this.store.finishTask(task.id, msg, task.attempts < 3 ? 2 ** task.attempts * 5e3 : void 0);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// ../packages/core/src/crew.ts
|
|
939
|
+
var MAX_TOOL_ROUNDS = 6;
|
|
940
|
+
var HISTORY = 20;
|
|
941
|
+
var DUPLICATE_SIMILARITY = 0.9;
|
|
942
|
+
var Crew = class _Crew extends EventEmitter {
|
|
943
|
+
constructor(store) {
|
|
944
|
+
super();
|
|
945
|
+
this.store = store;
|
|
946
|
+
this.config = resolveConfig(store);
|
|
947
|
+
this.provider = makeProvider(this.config);
|
|
948
|
+
this.worker = new Worker(store);
|
|
949
|
+
this.worker.handle("memory.extract", (p) => this.extractMemories(p));
|
|
950
|
+
this.worker.handle("memory.embed", () => this.backfillEmbeddings());
|
|
951
|
+
}
|
|
952
|
+
store;
|
|
953
|
+
config;
|
|
954
|
+
provider;
|
|
955
|
+
worker;
|
|
956
|
+
locks = /* @__PURE__ */ new Map();
|
|
957
|
+
statuses = /* @__PURE__ */ new Map();
|
|
958
|
+
embedOk = null;
|
|
959
|
+
static open(file) {
|
|
960
|
+
return new _Crew(new Store(file ?? envConfig().dbFile));
|
|
961
|
+
}
|
|
962
|
+
/** Re-read settings (after they were changed from the UI or CLI). */
|
|
963
|
+
reload() {
|
|
964
|
+
this.config = resolveConfig(this.store);
|
|
965
|
+
this.provider = makeProvider(this.config);
|
|
966
|
+
this.embedOk = null;
|
|
967
|
+
}
|
|
968
|
+
emitEvent(e) {
|
|
969
|
+
this.emit("event", e);
|
|
970
|
+
}
|
|
971
|
+
status(agentId) {
|
|
972
|
+
return this.statuses.get(agentId) ?? "idle";
|
|
973
|
+
}
|
|
974
|
+
setStatus(agentId, status, detail) {
|
|
975
|
+
this.statuses.set(agentId, status);
|
|
976
|
+
this.emitEvent({ type: "status", agentId, status, detail });
|
|
977
|
+
}
|
|
978
|
+
modelFor(agent2) {
|
|
979
|
+
return agent2.model || this.config.model;
|
|
980
|
+
}
|
|
981
|
+
requireAgent(idOrName) {
|
|
982
|
+
const agent2 = this.store.getAgent(idOrName);
|
|
983
|
+
if (!agent2) throw new Error(`No agent named "${idOrName}"`);
|
|
984
|
+
return agent2;
|
|
985
|
+
}
|
|
986
|
+
// ── memory ───────────────────────────────────────────────────────────
|
|
987
|
+
async embed(text) {
|
|
988
|
+
if (this.embedOk === false) return null;
|
|
989
|
+
try {
|
|
990
|
+
const [vec] = await this.provider.embed(this.config.embedModel, [text]);
|
|
991
|
+
this.embedOk = true;
|
|
992
|
+
return vec ?? null;
|
|
993
|
+
} catch {
|
|
994
|
+
this.embedOk = false;
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
async remember(agentId, content, type = "fact", importance = 0.6, source = "manual") {
|
|
999
|
+
const text = content.trim();
|
|
1000
|
+
if (!text) throw new Error("Memory is empty");
|
|
1001
|
+
const embedding = await this.embed(text);
|
|
1002
|
+
const near = embedding ? this.store.nearest(agentId, embedding) : null;
|
|
1003
|
+
if (near && near.similarity >= DUPLICATE_SIMILARITY) return near.memory;
|
|
1004
|
+
const [same] = this.store.searchMemories(agentId, text, { limit: 1 });
|
|
1005
|
+
if (same && normalize(same.content) === normalize(text)) return same;
|
|
1006
|
+
const memory2 = this.store.addMemory({ agentId, content: text, type, importance, source, embedding });
|
|
1007
|
+
this.emitEvent({ type: "memory", agentId, memory: memory2 });
|
|
1008
|
+
return memory2;
|
|
1009
|
+
}
|
|
1010
|
+
async recall(agentId, query, limit = 6) {
|
|
1011
|
+
const embedding = await this.embed(query);
|
|
1012
|
+
return this.store.searchMemories(agentId, query, { limit, embedding });
|
|
1013
|
+
}
|
|
1014
|
+
// ── chat ─────────────────────────────────────────────────────────────
|
|
1015
|
+
/** Run one turn: user message → (tools)* → assistant reply. Turns of one agent are serialized. */
|
|
1016
|
+
async chat(input) {
|
|
1017
|
+
const agent2 = this.requireAgent(input.agent);
|
|
1018
|
+
const prev = this.locks.get(agent2.id) ?? Promise.resolve();
|
|
1019
|
+
const run = prev.catch(() => {
|
|
1020
|
+
}).then(() => this.turn(agent2, input));
|
|
1021
|
+
this.locks.set(agent2.id, run);
|
|
1022
|
+
try {
|
|
1023
|
+
return await run;
|
|
1024
|
+
} finally {
|
|
1025
|
+
if (this.locks.get(agent2.id) === run) this.locks.delete(agent2.id);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
async turn(agent2, input) {
|
|
1029
|
+
const channel = input.channel ?? "web";
|
|
1030
|
+
const conv = this.store.conversation(agent2.id, channel, input.channelRef ?? "default");
|
|
1031
|
+
const history = this.store.messages(conv.id, HISTORY);
|
|
1032
|
+
const userMsg = this.store.addMessage(conv.id, "user", input.text);
|
|
1033
|
+
this.emitEvent({ type: "message", agentId: agent2.id, conversationId: conv.id, message: userMsg });
|
|
1034
|
+
this.setStatus(agent2.id, "thinking");
|
|
1035
|
+
const replyId = crypto.randomUUID();
|
|
1036
|
+
const toolLog = [];
|
|
1037
|
+
let answer = "";
|
|
1038
|
+
try {
|
|
1039
|
+
const relevant = await this.recall(agent2.id, input.text, 6);
|
|
1040
|
+
const core = this.store.coreMemories(agent2.id, 4).filter((m) => !relevant.some((r) => r.id === m.id));
|
|
1041
|
+
const folders = this.store.listFolders(agent2.id);
|
|
1042
|
+
const tools = [...memoryTools, ...folders.length ? fileTools : []];
|
|
1043
|
+
const messages = [
|
|
1044
|
+
{ role: "system", content: buildSystemPrompt({ agent: agent2, memories: [...core, ...relevant], folders, channel }) },
|
|
1045
|
+
...history.filter((m) => m.role === "user" || m.role === "assistant").map((m) => ({ role: m.role, content: m.content })),
|
|
1046
|
+
{ role: "user", content: input.text }
|
|
1047
|
+
];
|
|
1048
|
+
const ctx = {
|
|
1049
|
+
agent: agent2,
|
|
1050
|
+
store: this.store,
|
|
1051
|
+
remember: async (content, type, importance) => {
|
|
1052
|
+
const m = await this.remember(agent2.id, content, asType(type), importance ?? 0.7, "agent");
|
|
1053
|
+
return `Saved to memory: ${m.content}`;
|
|
1054
|
+
},
|
|
1055
|
+
recall: async (query) => {
|
|
1056
|
+
const hits = await this.recall(agent2.id, query, 8);
|
|
1057
|
+
return hits.length ? hits.map((h) => `- ${h.content}`).join("\n") : "Nothing found in memory.";
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
|
|
1061
|
+
const request = {
|
|
1062
|
+
model: this.modelFor(agent2),
|
|
1063
|
+
messages,
|
|
1064
|
+
signal: input.signal,
|
|
1065
|
+
onToken: (delta) => this.emitEvent({ type: "token", agentId: agent2.id, conversationId: conv.id, replyId, delta })
|
|
1066
|
+
};
|
|
1067
|
+
let res = await this.provider.chat({ ...request, tools: round < MAX_TOOL_ROUNDS ? tools : void 0 });
|
|
1068
|
+
if (!res.content.trim() && !res.toolCalls.length) res = await this.provider.chat(request);
|
|
1069
|
+
answer += res.content;
|
|
1070
|
+
if (!res.toolCalls.length) break;
|
|
1071
|
+
messages.push({ role: "assistant", content: res.content, toolCalls: res.toolCalls });
|
|
1072
|
+
this.setStatus(agent2.id, "working", res.toolCalls.map((c) => c.name).join(", "));
|
|
1073
|
+
for (const call of res.toolCalls) {
|
|
1074
|
+
const tool = tools.find((t) => t.name === call.name);
|
|
1075
|
+
this.emitEvent({ type: "tool", agentId: agent2.id, conversationId: conv.id, replyId, name: call.name, args: call.arguments });
|
|
1076
|
+
let result;
|
|
1077
|
+
try {
|
|
1078
|
+
result = tool ? await tool.run(call.arguments, ctx) : `Unknown tool: ${call.name}`;
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
result = `Error: ${err.message}`;
|
|
1081
|
+
}
|
|
1082
|
+
toolLog.push({ name: call.name, args: call.arguments, result: result.slice(0, 2e3) });
|
|
1083
|
+
this.emitEvent({
|
|
1084
|
+
type: "tool",
|
|
1085
|
+
agentId: agent2.id,
|
|
1086
|
+
conversationId: conv.id,
|
|
1087
|
+
replyId,
|
|
1088
|
+
name: call.name,
|
|
1089
|
+
args: call.arguments,
|
|
1090
|
+
result: result.slice(0, 2e3)
|
|
1091
|
+
});
|
|
1092
|
+
messages.push({ role: "tool", content: result, toolCallId: call.id, name: call.name });
|
|
1093
|
+
}
|
|
1094
|
+
this.setStatus(agent2.id, "thinking");
|
|
1095
|
+
}
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
this.setStatus(agent2.id, "idle");
|
|
1098
|
+
throw err;
|
|
1099
|
+
}
|
|
1100
|
+
const reply = this.store.addMessage(conv.id, "assistant", answer.trim() || "\u2026", {
|
|
1101
|
+
replyId,
|
|
1102
|
+
model: this.modelFor(agent2),
|
|
1103
|
+
...toolLog.length ? { tools: toolLog } : {}
|
|
1104
|
+
});
|
|
1105
|
+
this.emitEvent({ type: "message", agentId: agent2.id, conversationId: conv.id, message: reply });
|
|
1106
|
+
this.setStatus(agent2.id, "idle");
|
|
1107
|
+
this.store.enqueue("memory.extract", {
|
|
1108
|
+
agentId: agent2.id,
|
|
1109
|
+
user: input.text,
|
|
1110
|
+
assistant: reply.content
|
|
1111
|
+
});
|
|
1112
|
+
return reply;
|
|
1113
|
+
}
|
|
1114
|
+
// ── background jobs ──────────────────────────────────────────────────
|
|
1115
|
+
async extractMemories(p) {
|
|
1116
|
+
const agent2 = this.store.getAgent(p.agentId);
|
|
1117
|
+
if (!agent2 || p.user.trim().length < 12) return;
|
|
1118
|
+
this.setStatus(agent2.id, "remembering");
|
|
1119
|
+
try {
|
|
1120
|
+
const known = await this.recall(agent2.id, p.user, 8);
|
|
1121
|
+
const res = await this.provider.chat({
|
|
1122
|
+
model: this.modelFor(agent2),
|
|
1123
|
+
json: true,
|
|
1124
|
+
temperature: 0,
|
|
1125
|
+
messages: [
|
|
1126
|
+
{ role: "system", content: EXTRACT_PROMPT },
|
|
1127
|
+
{
|
|
1128
|
+
role: "user",
|
|
1129
|
+
content: (known.length ? `Already known:
|
|
1130
|
+
${known.map((k) => `- ${k.content}`).join("\n")}
|
|
1131
|
+
|
|
1132
|
+
` : "") + `User: ${p.user}
|
|
1133
|
+
|
|
1134
|
+
Assistant: ${p.assistant}`
|
|
1135
|
+
}
|
|
1136
|
+
]
|
|
1137
|
+
});
|
|
1138
|
+
const parsed = safeParse(res.content);
|
|
1139
|
+
for (const m of parsed.slice(0, 5)) {
|
|
1140
|
+
if (typeof m.content !== "string" || m.content.length < 6) continue;
|
|
1141
|
+
await this.remember(agent2.id, m.content, asType(m.type), clamp(Number(m.importance) || 0.5), "auto");
|
|
1142
|
+
}
|
|
1143
|
+
} finally {
|
|
1144
|
+
if (this.status(agent2.id) === "remembering") this.setStatus(agent2.id, "idle");
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
async backfillEmbeddings() {
|
|
1148
|
+
for (const m of this.store.memoriesWithoutEmbedding(100)) {
|
|
1149
|
+
const vec = await this.embed(m.content);
|
|
1150
|
+
if (!vec) return;
|
|
1151
|
+
this.store.setMemoryEmbedding(m.id, vec);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
start() {
|
|
1155
|
+
this.worker.start();
|
|
1156
|
+
this.store.enqueue("memory.embed", {});
|
|
1157
|
+
}
|
|
1158
|
+
async stop() {
|
|
1159
|
+
await this.worker.stop();
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
function makeProvider(config2) {
|
|
1163
|
+
return config2.provider === "openai" ? new OpenAIProvider(config2.openaiBaseUrl, config2.openaiApiKey) : new OllamaProvider(config2.ollamaHost);
|
|
1164
|
+
}
|
|
1165
|
+
function asType(t) {
|
|
1166
|
+
return t === "decision" || t === "preference" || t === "conversation" ? t : "fact";
|
|
1167
|
+
}
|
|
1168
|
+
function clamp(n) {
|
|
1169
|
+
return Math.min(1, Math.max(0, n));
|
|
1170
|
+
}
|
|
1171
|
+
function normalize(s) {
|
|
1172
|
+
return s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
1173
|
+
}
|
|
1174
|
+
function safeParse(s) {
|
|
1175
|
+
try {
|
|
1176
|
+
const data = JSON.parse(s);
|
|
1177
|
+
const list = Array.isArray(data) ? data : data?.memories;
|
|
1178
|
+
return Array.isArray(list) ? list : [];
|
|
1179
|
+
} catch {
|
|
1180
|
+
return [];
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ../packages/core/src/templates.ts
|
|
1185
|
+
var templates = {
|
|
1186
|
+
backend: {
|
|
1187
|
+
name: "Artem",
|
|
1188
|
+
role: "Senior backend engineer",
|
|
1189
|
+
avatar: "\u{1F6E0}\uFE0F",
|
|
1190
|
+
personality: "Calm, pragmatic, a bit of a perfectionist. Likes simple solutions, Postgres and boring technology.",
|
|
1191
|
+
systemPrompt: "Help with APIs, databases, architecture and code reviews. Prefer concrete code over theory."
|
|
1192
|
+
},
|
|
1193
|
+
frontend: {
|
|
1194
|
+
name: "Lena",
|
|
1195
|
+
role: "Frontend developer & designer",
|
|
1196
|
+
avatar: "\u{1F3A8}",
|
|
1197
|
+
personality: "Energetic and visual. Cares about details, accessibility and nice animations.",
|
|
1198
|
+
systemPrompt: "Help with React, CSS and UX. Suggest improvements to interfaces and copy."
|
|
1199
|
+
},
|
|
1200
|
+
pm: {
|
|
1201
|
+
name: "Max",
|
|
1202
|
+
role: "Product manager",
|
|
1203
|
+
avatar: "\u{1F4CB}",
|
|
1204
|
+
personality: "Structured, asks the right questions, keeps everyone focused on what matters this week.",
|
|
1205
|
+
systemPrompt: "Help plan work, write specs and break big goals into small tasks. Track decisions."
|
|
1206
|
+
},
|
|
1207
|
+
writer: {
|
|
1208
|
+
name: "Sofia",
|
|
1209
|
+
role: "Writer & editor",
|
|
1210
|
+
avatar: "\u270D\uFE0F",
|
|
1211
|
+
personality: "Warm, witty, and precise with words.",
|
|
1212
|
+
systemPrompt: "Help write and edit texts: posts, docs, emails. Keep the author's voice."
|
|
1213
|
+
},
|
|
1214
|
+
coach: {
|
|
1215
|
+
name: "Oleg",
|
|
1216
|
+
role: "Personal coach",
|
|
1217
|
+
avatar: "\u{1F9ED}",
|
|
1218
|
+
personality: "Supportive but honest. Remembers your goals and gently reminds you about them.",
|
|
1219
|
+
systemPrompt: "Help the user reflect, set goals and keep habits. Ask one good question at a time."
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
// ../apps/server/src/index.ts
|
|
1224
|
+
import { serve } from "@hono/node-server";
|
|
1225
|
+
|
|
1226
|
+
// ../packages/channels/src/telegram/index.ts
|
|
1227
|
+
import { randomInt } from "node:crypto";
|
|
1228
|
+
import { Bot } from "grammy";
|
|
1229
|
+
var PAIRED = "telegram.paired";
|
|
1230
|
+
var ACTIVE = "telegram.active";
|
|
1231
|
+
var TelegramChannel = class {
|
|
1232
|
+
constructor(crew, opts) {
|
|
1233
|
+
this.crew = crew;
|
|
1234
|
+
this.log = opts.log ?? ((m) => console.log(m));
|
|
1235
|
+
this.bot = new Bot(opts.token);
|
|
1236
|
+
this.setup();
|
|
1237
|
+
}
|
|
1238
|
+
crew;
|
|
1239
|
+
bot;
|
|
1240
|
+
pairingCode = String(randomInt(1e5, 999999));
|
|
1241
|
+
log;
|
|
1242
|
+
paired() {
|
|
1243
|
+
return this.crew.store.getSetting(PAIRED) ?? [];
|
|
1244
|
+
}
|
|
1245
|
+
activeAgent(chatId) {
|
|
1246
|
+
const map = this.crew.store.getSetting(ACTIVE) ?? {};
|
|
1247
|
+
const id2 = map[chatId];
|
|
1248
|
+
if (id2 && this.crew.store.getAgent(id2)) return id2;
|
|
1249
|
+
return this.crew.store.listAgents()[0]?.id ?? null;
|
|
1250
|
+
}
|
|
1251
|
+
setActive(chatId, agentId) {
|
|
1252
|
+
const map = this.crew.store.getSetting(ACTIVE) ?? {};
|
|
1253
|
+
map[chatId] = agentId;
|
|
1254
|
+
this.crew.store.setSetting(ACTIVE, map);
|
|
1255
|
+
}
|
|
1256
|
+
setup() {
|
|
1257
|
+
const bot = this.bot;
|
|
1258
|
+
bot.command(
|
|
1259
|
+
"start",
|
|
1260
|
+
(ctx) => ctx.reply(
|
|
1261
|
+
this.paired().includes(ctx.chat.id) ? "\u{1F44B} Crew is here. /agents \u2014 list colleagues, /use <name> \u2014 switch, /memory \u2014 what I remember." : "\u{1F44B} This is a private Crew workspace. Send /pair <code> with the code shown in your terminal."
|
|
1262
|
+
)
|
|
1263
|
+
);
|
|
1264
|
+
bot.command("pair", async (ctx) => {
|
|
1265
|
+
if (ctx.match.trim() !== this.pairingCode) return ctx.reply("\u274C Wrong code.");
|
|
1266
|
+
this.crew.store.setSetting(PAIRED, [.../* @__PURE__ */ new Set([...this.paired(), ctx.chat.id])]);
|
|
1267
|
+
this.log(`telegram: paired chat ${ctx.chat.id}`);
|
|
1268
|
+
return ctx.reply("\u2705 Paired! Say hi to your crew. /agents to see who's here.");
|
|
1269
|
+
});
|
|
1270
|
+
bot.use(async (ctx, next) => {
|
|
1271
|
+
if (ctx.chat && this.paired().includes(ctx.chat.id)) return next();
|
|
1272
|
+
if (ctx.chat) await ctx.reply("\u{1F512} Not paired. Send /pair <code>.");
|
|
1273
|
+
});
|
|
1274
|
+
bot.command("agents", (ctx) => {
|
|
1275
|
+
const active = this.activeAgent(ctx.chat.id);
|
|
1276
|
+
const list = this.crew.store.listAgents();
|
|
1277
|
+
if (!list.length) return ctx.reply("No agents yet \u2014 create one in the web UI or with `crew agent create`.");
|
|
1278
|
+
return ctx.reply(
|
|
1279
|
+
list.map((a) => `${a.id === active ? "\u25B6\uFE0F" : " "} ${a.avatar} *${a.name}* \u2014 ${a.role || "colleague"}`).join("\n"),
|
|
1280
|
+
{ parse_mode: "Markdown" }
|
|
1281
|
+
);
|
|
1282
|
+
});
|
|
1283
|
+
bot.command("use", (ctx) => {
|
|
1284
|
+
const agent2 = this.crew.store.getAgent(ctx.match.trim());
|
|
1285
|
+
if (!agent2) return ctx.reply("Unknown agent. /agents to list.");
|
|
1286
|
+
this.setActive(ctx.chat.id, agent2.id);
|
|
1287
|
+
return ctx.reply(`${agent2.avatar} Now talking to ${agent2.name}.`);
|
|
1288
|
+
});
|
|
1289
|
+
bot.command("memory", (ctx) => {
|
|
1290
|
+
const id2 = this.activeAgent(ctx.chat.id);
|
|
1291
|
+
if (!id2) return ctx.reply("No agents yet.");
|
|
1292
|
+
const mems = this.crew.store.listMemories(id2, 15);
|
|
1293
|
+
return ctx.reply(mems.length ? mems.map((m) => `\u2022 ${m.content}`).join("\n") : "Nothing remembered yet.");
|
|
1294
|
+
});
|
|
1295
|
+
bot.command("forget", (ctx) => {
|
|
1296
|
+
const id2 = this.activeAgent(ctx.chat.id);
|
|
1297
|
+
if (!id2) return;
|
|
1298
|
+
this.crew.store.clearConversation(this.crew.store.conversation(id2, "telegram", String(ctx.chat.id)).id);
|
|
1299
|
+
return ctx.reply("\u{1F9F9} Chat history cleared (long-term memory kept).");
|
|
1300
|
+
});
|
|
1301
|
+
bot.on("message:text", (ctx) => this.onText(ctx, ctx.message.text));
|
|
1302
|
+
bot.catch((err) => this.log(`telegram: ${err.message}`));
|
|
1303
|
+
}
|
|
1304
|
+
async onText(ctx, text) {
|
|
1305
|
+
const chatId = ctx.chat.id;
|
|
1306
|
+
const agentId = this.activeAgent(chatId);
|
|
1307
|
+
if (!agentId) return ctx.reply("No agents yet \u2014 create one first.");
|
|
1308
|
+
let target = agentId;
|
|
1309
|
+
const mention = text.match(/^@?([\p{L}\d_-]+)[,:]\s+/u);
|
|
1310
|
+
const named = mention && this.crew.store.getAgent(mention[1]);
|
|
1311
|
+
if (named) {
|
|
1312
|
+
target = named.id;
|
|
1313
|
+
text = text.slice(mention[0].length);
|
|
1314
|
+
}
|
|
1315
|
+
const typing = setInterval(() => ctx.replyWithChatAction("typing").catch(() => {
|
|
1316
|
+
}), 4e3);
|
|
1317
|
+
await ctx.replyWithChatAction("typing").catch(() => {
|
|
1318
|
+
});
|
|
1319
|
+
try {
|
|
1320
|
+
const reply = await this.crew.chat({ agent: target, text, channel: "telegram", channelRef: String(chatId) });
|
|
1321
|
+
const agent2 = this.crew.store.getAgent(target);
|
|
1322
|
+
const body = named ? `${agent2.avatar} ${agent2.name}:
|
|
1323
|
+
${reply.content}` : reply.content;
|
|
1324
|
+
for (const part of chunk(body, 4e3)) {
|
|
1325
|
+
await ctx.reply(part, { parse_mode: "Markdown" }).catch(() => ctx.reply(part));
|
|
1326
|
+
}
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
await ctx.reply(`\u26A0\uFE0F ${err.message}`);
|
|
1329
|
+
} finally {
|
|
1330
|
+
clearInterval(typing);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
async start() {
|
|
1334
|
+
const me = await this.bot.api.getMe();
|
|
1335
|
+
this.log(`telegram: @${me.username} is online \u2014 pairing code ${this.pairingCode}`);
|
|
1336
|
+
void this.bot.start({ drop_pending_updates: true });
|
|
1337
|
+
return me;
|
|
1338
|
+
}
|
|
1339
|
+
async stop() {
|
|
1340
|
+
await this.bot.stop();
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
function chunk(s, size) {
|
|
1344
|
+
const out = [];
|
|
1345
|
+
for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size));
|
|
1346
|
+
return out;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ../apps/server/src/app.ts
|
|
1350
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1351
|
+
import { join as join3 } from "node:path";
|
|
1352
|
+
import { stat as stat2 } from "node:fs/promises";
|
|
1353
|
+
import { Hono } from "hono";
|
|
1354
|
+
import { HTTPException } from "hono/http-exception";
|
|
1355
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
1356
|
+
import { createNodeWebSocket } from "@hono/node-ws";
|
|
1357
|
+
|
|
1358
|
+
// ../apps/server/src/version.ts
|
|
1359
|
+
var VERSION = "0.1.0";
|
|
1360
|
+
|
|
1361
|
+
// ../apps/server/src/app.ts
|
|
1362
|
+
var SECRET_KEYS = /* @__PURE__ */ new Set(["openaiApiKey", "telegramToken"]);
|
|
1363
|
+
function createApp(crew, opts = {}) {
|
|
1364
|
+
const app = new Hono();
|
|
1365
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
|
1366
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1367
|
+
const broadcast = (e) => {
|
|
1368
|
+
const data = JSON.stringify(e);
|
|
1369
|
+
for (const ws of sockets) ws.send(data);
|
|
1370
|
+
};
|
|
1371
|
+
crew.on("event", broadcast);
|
|
1372
|
+
const agentOr404 = (id2) => {
|
|
1373
|
+
const agent2 = crew.store.getAgent(id2);
|
|
1374
|
+
if (!agent2) throw new HTTPException(404, { message: "Agent not found" });
|
|
1375
|
+
return agent2;
|
|
1376
|
+
};
|
|
1377
|
+
const withStats = (a) => ({
|
|
1378
|
+
...a,
|
|
1379
|
+
status: crew.status(a.id),
|
|
1380
|
+
memoryCount: crew.store.countMemories(a.id),
|
|
1381
|
+
effectiveModel: crew.modelFor(a)
|
|
1382
|
+
});
|
|
1383
|
+
app.onError((err, c) => {
|
|
1384
|
+
if (err instanceof HTTPException) return c.json({ error: err.message }, err.status);
|
|
1385
|
+
console.error(err);
|
|
1386
|
+
return c.json({ error: err.message }, 500);
|
|
1387
|
+
});
|
|
1388
|
+
const api = new Hono();
|
|
1389
|
+
api.get("/health", (c) => c.json({ ok: true, version: VERSION }));
|
|
1390
|
+
api.get("/status", async (c) => {
|
|
1391
|
+
const online = await crew.provider.ping();
|
|
1392
|
+
const models = online ? await crew.provider.models().catch(() => []) : [];
|
|
1393
|
+
return c.json({
|
|
1394
|
+
version: VERSION,
|
|
1395
|
+
provider: crew.config.provider,
|
|
1396
|
+
online,
|
|
1397
|
+
model: crew.config.model,
|
|
1398
|
+
embedModel: crew.config.embedModel,
|
|
1399
|
+
modelInstalled: models.some((m) => m.name === crew.config.model || m.name === `${crew.config.model}:latest`),
|
|
1400
|
+
models,
|
|
1401
|
+
telegram: Boolean(crew.config.telegramToken)
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
api.get("/settings", (c) => {
|
|
1405
|
+
const cfg = crew.config;
|
|
1406
|
+
const out = {};
|
|
1407
|
+
for (const k of SETTING_KEYS) {
|
|
1408
|
+
const v = cfg[k];
|
|
1409
|
+
out[k] = SECRET_KEYS.has(k) ? v ? "\u2022\u2022\u2022\u2022\u2022\u2022" : "" : v;
|
|
1410
|
+
}
|
|
1411
|
+
return c.json(out);
|
|
1412
|
+
});
|
|
1413
|
+
api.put("/settings", async (c) => {
|
|
1414
|
+
const body = await c.req.json();
|
|
1415
|
+
for (const k of SETTING_KEYS) {
|
|
1416
|
+
if (!(k in body)) continue;
|
|
1417
|
+
if (SECRET_KEYS.has(k) && body[k] === "\u2022\u2022\u2022\u2022\u2022\u2022") continue;
|
|
1418
|
+
crew.store.setSetting(k, String(body[k] ?? ""));
|
|
1419
|
+
}
|
|
1420
|
+
crew.reload();
|
|
1421
|
+
return c.json({ ok: true });
|
|
1422
|
+
});
|
|
1423
|
+
api.get("/templates", (c) => c.json(templates));
|
|
1424
|
+
api.get("/agents", (c) => c.json(crew.store.listAgents().map(withStats)));
|
|
1425
|
+
api.post("/agents", async (c) => {
|
|
1426
|
+
const body = await c.req.json();
|
|
1427
|
+
if (!body?.name?.trim()) throw new HTTPException(400, { message: "Name is required" });
|
|
1428
|
+
if (crew.store.getAgent(body.name)) throw new HTTPException(409, { message: "An agent with this name already exists" });
|
|
1429
|
+
const agent2 = crew.store.createAgent(body);
|
|
1430
|
+
crew.emitEvent({ type: "agents" });
|
|
1431
|
+
return c.json(withStats(agent2), 201);
|
|
1432
|
+
});
|
|
1433
|
+
api.post("/agents/import", async (c) => {
|
|
1434
|
+
const body = await c.req.json();
|
|
1435
|
+
const name = crew.store.getAgent(body.agent?.name) ? `${body.agent.name} (2)` : body.agent?.name;
|
|
1436
|
+
if (!name) throw new HTTPException(400, { message: "Not a Crew export" });
|
|
1437
|
+
const agent2 = crew.store.createAgent({ ...body.agent, name });
|
|
1438
|
+
for (const m of body.memories ?? []) {
|
|
1439
|
+
await crew.remember(agent2.id, m.content, m.type, m.importance, "import");
|
|
1440
|
+
}
|
|
1441
|
+
crew.emitEvent({ type: "agents" });
|
|
1442
|
+
return c.json(withStats(agent2), 201);
|
|
1443
|
+
});
|
|
1444
|
+
api.get("/agents/:id", (c) => c.json(withStats(agentOr404(c.req.param("id")))));
|
|
1445
|
+
api.patch("/agents/:id", async (c) => {
|
|
1446
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1447
|
+
const updated = crew.store.updateAgent(agent2.id, await c.req.json());
|
|
1448
|
+
crew.emitEvent({ type: "agents" });
|
|
1449
|
+
return c.json(withStats(updated));
|
|
1450
|
+
});
|
|
1451
|
+
api.delete("/agents/:id", (c) => {
|
|
1452
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1453
|
+
crew.store.deleteAgent(agent2.id);
|
|
1454
|
+
crew.emitEvent({ type: "agents" });
|
|
1455
|
+
return c.json({ ok: true });
|
|
1456
|
+
});
|
|
1457
|
+
api.get("/agents/:id/export", (c) => {
|
|
1458
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1459
|
+
const { name, role, personality, systemPrompt, model, avatar, color } = agent2;
|
|
1460
|
+
return c.json({
|
|
1461
|
+
crew: 1,
|
|
1462
|
+
agent: { name, role, personality, systemPrompt, model, avatar, color },
|
|
1463
|
+
memories: crew.store.listMemories(agent2.id, 1e4).map(({ content, type, importance }) => ({ content, type, importance }))
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
api.get("/agents/:id/messages", (c) => {
|
|
1467
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1468
|
+
const conv = crew.store.conversation(agent2.id, c.req.query("channel") ?? "web", c.req.query("ref") ?? "default");
|
|
1469
|
+
return c.json({ conversationId: conv.id, messages: crew.store.messages(conv.id, Number(c.req.query("limit") ?? 100)) });
|
|
1470
|
+
});
|
|
1471
|
+
api.get("/agents/:id/conversations", (c) => c.json(crew.store.listConversations(agentOr404(c.req.param("id")).id)));
|
|
1472
|
+
api.delete("/agents/:id/messages", (c) => {
|
|
1473
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1474
|
+
crew.store.clearConversation(crew.store.conversation(agent2.id, "web").id);
|
|
1475
|
+
return c.json({ ok: true });
|
|
1476
|
+
});
|
|
1477
|
+
api.post("/agents/:id/chat", async (c) => {
|
|
1478
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1479
|
+
const { text, channel, ref } = await c.req.json();
|
|
1480
|
+
if (!text?.trim()) throw new HTTPException(400, { message: "text is required" });
|
|
1481
|
+
const reply = await crew.chat({ agent: agent2.id, text, channel: channel ?? "api", channelRef: ref ?? "default" });
|
|
1482
|
+
return c.json(reply);
|
|
1483
|
+
});
|
|
1484
|
+
api.get("/agents/:id/memories", async (c) => {
|
|
1485
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1486
|
+
const q = c.req.query("q");
|
|
1487
|
+
return c.json(q ? await crew.recall(agent2.id, q, 20) : crew.store.listMemories(agent2.id));
|
|
1488
|
+
});
|
|
1489
|
+
api.post("/agents/:id/memories", async (c) => {
|
|
1490
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1491
|
+
const { content, type, importance } = await c.req.json();
|
|
1492
|
+
if (!content?.trim()) throw new HTTPException(400, { message: "content is required" });
|
|
1493
|
+
return c.json(await crew.remember(agent2.id, content, type ?? "fact", importance ?? 0.7, "manual"), 201);
|
|
1494
|
+
});
|
|
1495
|
+
api.delete("/memories/:id", (c) => {
|
|
1496
|
+
crew.store.deleteMemory(c.req.param("id"));
|
|
1497
|
+
return c.json({ ok: true });
|
|
1498
|
+
});
|
|
1499
|
+
api.get("/agents/:id/folders", (c) => c.json(crew.store.listFolders(agentOr404(c.req.param("id")).id)));
|
|
1500
|
+
api.post("/agents/:id/folders", async (c) => {
|
|
1501
|
+
const agent2 = agentOr404(c.req.param("id"));
|
|
1502
|
+
const { path, mode } = await c.req.json();
|
|
1503
|
+
const info = await stat2(path).catch(() => null);
|
|
1504
|
+
if (!info?.isDirectory()) throw new HTTPException(400, { message: `Not a directory: ${path}` });
|
|
1505
|
+
return c.json(crew.store.grantFolder(agent2.id, path, mode === "rw" ? "rw" : "ro"), 201);
|
|
1506
|
+
});
|
|
1507
|
+
api.delete("/folders/:id", (c) => {
|
|
1508
|
+
crew.store.revokeFolder(c.req.param("id"));
|
|
1509
|
+
return c.json({ ok: true });
|
|
1510
|
+
});
|
|
1511
|
+
api.get("/tasks", (c) => c.json(crew.store.listTasks()));
|
|
1512
|
+
app.route("/api", api);
|
|
1513
|
+
app.get(
|
|
1514
|
+
"/ws",
|
|
1515
|
+
upgradeWebSocket(() => ({
|
|
1516
|
+
onOpen(_e, ws) {
|
|
1517
|
+
sockets.add(ws);
|
|
1518
|
+
},
|
|
1519
|
+
onClose(_e, ws) {
|
|
1520
|
+
sockets.delete(ws);
|
|
1521
|
+
},
|
|
1522
|
+
async onMessage(e, ws) {
|
|
1523
|
+
let msg;
|
|
1524
|
+
try {
|
|
1525
|
+
msg = JSON.parse(String(e.data));
|
|
1526
|
+
} catch {
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (msg.type === "chat" && msg.agentId && msg.text?.trim()) {
|
|
1530
|
+
crew.chat({ agent: msg.agentId, text: msg.text, channel: "web" }).catch(
|
|
1531
|
+
(err) => ws.send(JSON.stringify({ type: "error", agentId: msg.agentId, message: err.message }))
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}))
|
|
1536
|
+
);
|
|
1537
|
+
const webDir2 = opts.webDir;
|
|
1538
|
+
if (webDir2 && existsSync(join3(webDir2, "index.html"))) {
|
|
1539
|
+
const index = readFileSync(join3(webDir2, "index.html"), "utf8");
|
|
1540
|
+
app.use("/*", serveStatic({ root: webDir2 }));
|
|
1541
|
+
app.get("*", (c) => c.html(index));
|
|
1542
|
+
} else {
|
|
1543
|
+
app.get(
|
|
1544
|
+
"/",
|
|
1545
|
+
(c) => c.text("Crew API is running. Start the web UI with `pnpm dev` or build it with `pnpm build`.")
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
return { app, injectWebSocket };
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ../apps/server/src/index.ts
|
|
1552
|
+
async function startServer(opts = {}) {
|
|
1553
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
1554
|
+
const env = envConfig();
|
|
1555
|
+
const crew = Crew.open(opts.dbFile ?? env.dbFile);
|
|
1556
|
+
crew.start();
|
|
1557
|
+
const { app, injectWebSocket } = createApp(crew, { webDir: opts.webDir });
|
|
1558
|
+
const port = opts.port ?? env.port;
|
|
1559
|
+
const host = opts.host ?? process.env.CREW_HOST ?? "127.0.0.1";
|
|
1560
|
+
const server = await new Promise((resolve4, reject) => {
|
|
1561
|
+
const s = serve({ fetch: app.fetch, port, hostname: host }, () => resolve4(s));
|
|
1562
|
+
s.once("error", reject);
|
|
1563
|
+
});
|
|
1564
|
+
injectWebSocket(server);
|
|
1565
|
+
let telegram = null;
|
|
1566
|
+
if (opts.telegram !== false && crew.config.telegramToken) {
|
|
1567
|
+
telegram = new TelegramChannel(crew, { token: crew.config.telegramToken, log });
|
|
1568
|
+
await telegram.start().catch((err) => {
|
|
1569
|
+
log(`telegram: failed to start \u2014 ${err.message}`);
|
|
1570
|
+
telegram = null;
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
const shownHost = host === "0.0.0.0" || host === "127.0.0.1" ? "localhost" : host;
|
|
1574
|
+
return {
|
|
1575
|
+
crew,
|
|
1576
|
+
server,
|
|
1577
|
+
url: `http://${shownHost}:${port}`,
|
|
1578
|
+
telegram,
|
|
1579
|
+
async close() {
|
|
1580
|
+
await telegram?.stop();
|
|
1581
|
+
await crew.stop();
|
|
1582
|
+
await new Promise((r) => server.close(r));
|
|
1583
|
+
crew.store.close();
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// src/commands/start.ts
|
|
1589
|
+
import open from "open";
|
|
1590
|
+
|
|
1591
|
+
// src/ui.ts
|
|
1592
|
+
import pc from "picocolors";
|
|
1593
|
+
var logo = () => [
|
|
1594
|
+
pc.magenta(" \u25CF \u25CF ") + pc.bold("crew"),
|
|
1595
|
+
pc.cyan(" \u25CF ") + pc.dim("your AI colleagues, at home")
|
|
1596
|
+
].join("\n");
|
|
1597
|
+
var ok = (msg) => console.log(`${pc.green("\u2714")} ${msg}`);
|
|
1598
|
+
var warn = (msg) => console.log(`${pc.yellow("!")} ${msg}`);
|
|
1599
|
+
var fail = (msg) => console.error(`${pc.red("\u2716")} ${msg}`);
|
|
1600
|
+
var dim = pc.dim;
|
|
1601
|
+
var bold = pc.bold;
|
|
1602
|
+
var cyan = pc.cyan;
|
|
1603
|
+
var magenta = pc.magenta;
|
|
1604
|
+
|
|
1605
|
+
// src/web-dir.ts
|
|
1606
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1607
|
+
import { fileURLToPath } from "node:url";
|
|
1608
|
+
function webDir() {
|
|
1609
|
+
const candidates = [
|
|
1610
|
+
new URL("./web", import.meta.url),
|
|
1611
|
+
new URL("../../apps/web/dist", import.meta.url)
|
|
1612
|
+
].map((u) => fileURLToPath(u));
|
|
1613
|
+
return candidates.find((p) => existsSync2(`${p}/index.html`));
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// src/commands/start.ts
|
|
1617
|
+
async function start(flags) {
|
|
1618
|
+
console.log(`
|
|
1619
|
+
${logo()}
|
|
1620
|
+
`);
|
|
1621
|
+
const running = await startServer({
|
|
1622
|
+
port: flags.port ? Number(flags.port) : void 0,
|
|
1623
|
+
host: flags.host,
|
|
1624
|
+
webDir: webDir(),
|
|
1625
|
+
telegram: flags.telegram,
|
|
1626
|
+
log: (m) => console.log(dim(` ${m}`))
|
|
1627
|
+
});
|
|
1628
|
+
const { crew } = running;
|
|
1629
|
+
const online = await crew.provider.ping();
|
|
1630
|
+
const agents = crew.store.listAgents();
|
|
1631
|
+
ok(`Crew is running at ${bold(cyan(running.url))}`);
|
|
1632
|
+
console.log(dim(` data ${crew.store.file}`));
|
|
1633
|
+
console.log(dim(` provider ${crew.config.provider} \xB7 ${crew.config.model}`));
|
|
1634
|
+
console.log(dim(` agents ${agents.length ? agents.map((a) => a.name).join(", ") : "none yet"}`));
|
|
1635
|
+
if (!online) warn(`Model provider is offline. Start Ollama: ${bold("ollama serve")}`);
|
|
1636
|
+
if (running.telegram) ok(`Telegram bot online \u2014 send ${bold(`/pair ${running.telegram.pairingCode}`)} to it`);
|
|
1637
|
+
console.log();
|
|
1638
|
+
if (flags.open) open(running.url).catch(() => {
|
|
1639
|
+
});
|
|
1640
|
+
const shutdown = async () => {
|
|
1641
|
+
console.log(dim("\n shutting down\u2026"));
|
|
1642
|
+
await running.close();
|
|
1643
|
+
process.exit(0);
|
|
1644
|
+
};
|
|
1645
|
+
process.once("SIGINT", shutdown);
|
|
1646
|
+
process.once("SIGTERM", shutdown);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// src/commands/agent.ts
|
|
1650
|
+
import { resolve as resolve3 } from "node:path";
|
|
1651
|
+
import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
1652
|
+
function withCrew(fn) {
|
|
1653
|
+
return async () => {
|
|
1654
|
+
const crew = Crew.open();
|
|
1655
|
+
try {
|
|
1656
|
+
return await fn(crew);
|
|
1657
|
+
} finally {
|
|
1658
|
+
await crew.stop();
|
|
1659
|
+
crew.store.close();
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
var agentCreate = (name, f) => withCrew((crew) => {
|
|
1664
|
+
const base = f.template ? templates[f.template] : void 0;
|
|
1665
|
+
if (f.template && !base) {
|
|
1666
|
+
fail(`Unknown template "${f.template}". Available: ${Object.keys(templates).join(", ")}`);
|
|
1667
|
+
process.exitCode = 1;
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (crew.store.getAgent(name)) {
|
|
1671
|
+
fail(`Agent "${name}" already exists`);
|
|
1672
|
+
process.exitCode = 1;
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const agent2 = crew.store.createAgent({
|
|
1676
|
+
...base,
|
|
1677
|
+
name,
|
|
1678
|
+
role: f.role ?? base?.role,
|
|
1679
|
+
personality: f.persona ?? base?.personality,
|
|
1680
|
+
systemPrompt: f.prompt ?? base?.systemPrompt,
|
|
1681
|
+
model: f.model ?? null,
|
|
1682
|
+
avatar: f.avatar ?? base?.avatar
|
|
1683
|
+
});
|
|
1684
|
+
for (const folder of f.folder ?? []) crew.store.grantFolder(agent2.id, resolve3(folder), f.rw ? "rw" : "ro");
|
|
1685
|
+
ok(`${agent2.avatar} ${bold(agent2.name)} joined the crew${agent2.role ? dim(` \u2014 ${agent2.role}`) : ""}`);
|
|
1686
|
+
})();
|
|
1687
|
+
var agentList = () => withCrew((crew) => {
|
|
1688
|
+
const agents = crew.store.listAgents();
|
|
1689
|
+
if (!agents.length) return console.log(dim("No agents yet. Try: crew agent create Artem --template backend"));
|
|
1690
|
+
for (const a of agents) {
|
|
1691
|
+
const mem = crew.store.countMemories(a.id);
|
|
1692
|
+
console.log(`${a.avatar} ${bold(a.name.padEnd(14))} ${(a.role || "\u2014").padEnd(32)} ${dim(`${mem} memories \xB7 ${crew.modelFor(a)}`)}`);
|
|
1693
|
+
}
|
|
1694
|
+
})();
|
|
1695
|
+
var agentRemove = (name) => withCrew((crew) => {
|
|
1696
|
+
const a = crew.requireAgent(name);
|
|
1697
|
+
crew.store.deleteAgent(a.id);
|
|
1698
|
+
ok(`Removed ${a.name}`);
|
|
1699
|
+
})();
|
|
1700
|
+
var agentGrant = (name, folder, f) => withCrew((crew) => {
|
|
1701
|
+
const a = crew.requireAgent(name);
|
|
1702
|
+
const g = crew.store.grantFolder(a.id, resolve3(folder), f.rw ? "rw" : "ro");
|
|
1703
|
+
ok(`${a.name} can now ${g.mode === "rw" ? "read and write" : "read"} ${g.path}`);
|
|
1704
|
+
})();
|
|
1705
|
+
var agentExport = (name, f) => withCrew((crew) => {
|
|
1706
|
+
const a = crew.requireAgent(name);
|
|
1707
|
+
const dump = {
|
|
1708
|
+
crew: 1,
|
|
1709
|
+
agent: { name: a.name, role: a.role, personality: a.personality, systemPrompt: a.systemPrompt, model: a.model, avatar: a.avatar, color: a.color },
|
|
1710
|
+
memories: crew.store.listMemories(a.id, 1e4).map(({ content, type, importance }) => ({ content, type, importance }))
|
|
1711
|
+
};
|
|
1712
|
+
const out = f.out ?? `${a.name}.crew.json`;
|
|
1713
|
+
writeFileSync(out, JSON.stringify(dump, null, 2));
|
|
1714
|
+
ok(`Exported ${a.name} with ${dump.memories.length} memories \u2192 ${out}`);
|
|
1715
|
+
})();
|
|
1716
|
+
var agentImport = (file) => withCrew(async (crew) => {
|
|
1717
|
+
const dump = JSON.parse(readFileSync2(file, "utf8"));
|
|
1718
|
+
const name = crew.store.getAgent(dump.agent.name) ? `${dump.agent.name} (2)` : dump.agent.name;
|
|
1719
|
+
const a = crew.store.createAgent({ ...dump.agent, name });
|
|
1720
|
+
for (const m of dump.memories ?? []) await crew.remember(a.id, m.content, m.type, m.importance, "import");
|
|
1721
|
+
ok(`Imported ${a.avatar} ${bold(a.name)} with ${dump.memories?.length ?? 0} memories`);
|
|
1722
|
+
})();
|
|
1723
|
+
|
|
1724
|
+
// src/commands/chat.ts
|
|
1725
|
+
import { createInterface } from "node:readline/promises";
|
|
1726
|
+
import { stdin, stdout } from "node:process";
|
|
1727
|
+
async function chat(name, f) {
|
|
1728
|
+
const crew = Crew.open();
|
|
1729
|
+
crew.start();
|
|
1730
|
+
const agent2 = crew.store.getAgent(name);
|
|
1731
|
+
if (!agent2) {
|
|
1732
|
+
fail(`No agent named "${name}". Create one: crew agent create ${name}`);
|
|
1733
|
+
process.exit(1);
|
|
1734
|
+
}
|
|
1735
|
+
crew.on("event", (e) => {
|
|
1736
|
+
if (!("agentId" in e) || e.agentId !== agent2.id) return;
|
|
1737
|
+
if (e.type === "token") stdout.write(e.delta);
|
|
1738
|
+
if (e.type === "tool" && e.result === void 0) stdout.write(dim(`
|
|
1739
|
+
\u21B3 ${e.name} ${JSON.stringify(e.args).slice(0, 80)}
|
|
1740
|
+
`));
|
|
1741
|
+
});
|
|
1742
|
+
const ask = async (text) => {
|
|
1743
|
+
stdout.write(`
|
|
1744
|
+
${agent2.avatar} ${bold(magenta(agent2.name))} `);
|
|
1745
|
+
try {
|
|
1746
|
+
await crew.chat({ agent: agent2.id, text, channel: "cli" });
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
fail(err.message);
|
|
1749
|
+
}
|
|
1750
|
+
stdout.write("\n");
|
|
1751
|
+
};
|
|
1752
|
+
if (f.message) {
|
|
1753
|
+
await ask(f.message);
|
|
1754
|
+
} else {
|
|
1755
|
+
console.log(dim(`Chatting with ${agent2.name}. /memory \u2014 show memories, /exit \u2014 quit.
|
|
1756
|
+
`));
|
|
1757
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
1758
|
+
for (; ; ) {
|
|
1759
|
+
const text = (await rl.question(cyan("\nyou \u203A "))).trim();
|
|
1760
|
+
if (!text) continue;
|
|
1761
|
+
if (text === "/exit" || text === "/quit") break;
|
|
1762
|
+
if (text === "/memory") {
|
|
1763
|
+
for (const m of crew.store.listMemories(agent2.id, 20)) console.log(dim(` \u2022 [${m.type}] ${m.content}`));
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
await ask(text);
|
|
1767
|
+
}
|
|
1768
|
+
rl.close();
|
|
1769
|
+
}
|
|
1770
|
+
await crew.worker.drain();
|
|
1771
|
+
await crew.stop();
|
|
1772
|
+
crew.store.close();
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// src/commands/memory.ts
|
|
1776
|
+
var memoryList = (name, f) => withCrew(async (crew) => {
|
|
1777
|
+
const a = crew.requireAgent(name);
|
|
1778
|
+
const list = f.search ? await crew.recall(a.id, f.search, Number(f.limit ?? 20)) : crew.store.listMemories(a.id, Number(f.limit ?? 50));
|
|
1779
|
+
if (!list.length) return console.log(dim(`${a.name} doesn't remember anything yet.`));
|
|
1780
|
+
console.log(bold(`${a.avatar} ${a.name} remembers:
|
|
1781
|
+
`));
|
|
1782
|
+
for (const m of list) console.log(` \u2022 ${m.content} ${dim(`[${m.type} \xB7 ${m.importance.toFixed(1)}]`)}`);
|
|
1783
|
+
})();
|
|
1784
|
+
var memoryAdd = (name, text, f) => withCrew(async (crew) => {
|
|
1785
|
+
const a = crew.requireAgent(name);
|
|
1786
|
+
await crew.remember(a.id, text, f.type ?? "fact", 0.8, "cli");
|
|
1787
|
+
ok(`${a.name} will remember that.`);
|
|
1788
|
+
})();
|
|
1789
|
+
|
|
1790
|
+
// src/commands/doctor.ts
|
|
1791
|
+
var doctor = () => withCrew(async (crew) => {
|
|
1792
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
1793
|
+
if (major > 22 || major === 22 && minor >= 13) ok(`Node.js ${process.versions.node}`);
|
|
1794
|
+
else fail(`Node.js ${process.versions.node} \u2014 Crew needs 22.13+ (for built-in SQLite)`);
|
|
1795
|
+
ok(`Database ${dim(crew.store.file)}`);
|
|
1796
|
+
const { provider, model, embedModel, ollamaHost, openaiBaseUrl } = crew.config;
|
|
1797
|
+
const url = provider === "ollama" ? ollamaHost : openaiBaseUrl;
|
|
1798
|
+
if (!await crew.provider.ping()) {
|
|
1799
|
+
fail(`${provider} is not reachable at ${url}`);
|
|
1800
|
+
if (provider === "ollama") console.log(dim(" install: https://ollama.com \xB7 then run: ollama serve"));
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
ok(`${provider} is reachable at ${url}`);
|
|
1804
|
+
const models = (await crew.provider.models()).map((m) => m.name);
|
|
1805
|
+
const has = (m) => models.includes(m) || models.includes(`${m}:latest`);
|
|
1806
|
+
if (has(model)) ok(`Chat model ${bold(model)}`);
|
|
1807
|
+
else warn(`Chat model ${bold(model)} is missing \u2014 ${provider === "ollama" ? `ollama pull ${model}` : "check the model name"}`);
|
|
1808
|
+
if (has(embedModel)) ok(`Embedding model ${bold(embedModel)}`);
|
|
1809
|
+
else warn(`Embedding model ${bold(embedModel)} is missing \u2014 memory falls back to keyword search (ollama pull ${embedModel})`);
|
|
1810
|
+
if (crew.config.telegramToken) ok("Telegram token configured");
|
|
1811
|
+
else console.log(dim(" \xB7 Telegram not configured (crew config set telegramToken <token>)"));
|
|
1812
|
+
})();
|
|
1813
|
+
|
|
1814
|
+
// src/commands/config.ts
|
|
1815
|
+
var SECRET = /* @__PURE__ */ new Set(["openaiApiKey", "telegramToken"]);
|
|
1816
|
+
var configList = () => withCrew((crew) => {
|
|
1817
|
+
for (const k of SETTING_KEYS) {
|
|
1818
|
+
const v = crew.config[k];
|
|
1819
|
+
console.log(`${k.padEnd(16)} ${SECRET.has(k) ? v ? "\u2022\u2022\u2022\u2022\u2022\u2022" : dim("\u2014") : v || dim("\u2014")}`);
|
|
1820
|
+
}
|
|
1821
|
+
})();
|
|
1822
|
+
var configSet = (key, value) => withCrew((crew) => {
|
|
1823
|
+
if (!SETTING_KEYS.includes(key)) {
|
|
1824
|
+
fail(`Unknown key. One of: ${SETTING_KEYS.join(", ")}`);
|
|
1825
|
+
process.exitCode = 1;
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
crew.store.setSetting(key, value);
|
|
1829
|
+
ok(`${key} saved`);
|
|
1830
|
+
})();
|
|
1831
|
+
|
|
1832
|
+
// src/index.ts
|
|
1833
|
+
var program = new Command().name("crew").description("A lightweight local-first workspace for your persistent AI colleagues").version(VERSION);
|
|
1834
|
+
program.command("start", { isDefault: true }).description("start the Crew server and web UI").option("-p, --port <port>", "port to listen on (default 3000)").option("--host <host>", "interface to bind (default 127.0.0.1)").option("-o, --open", "open the browser").option("--no-telegram", "don't start the Telegram bot").action(start);
|
|
1835
|
+
program.command("dev").description("start Crew and open it in the browser").option("-p, --port <port>", "port to listen on").action((f) => start({ ...f, open: true }));
|
|
1836
|
+
var agent = program.command("agent").description("manage your colleagues");
|
|
1837
|
+
agent.command("create <name>").description("create a new agent").option("-r, --role <role>", "role, e.g. 'backend engineer'").option("--persona <text>", "personality / character").option("--prompt <text>", "extra instructions").option("-m, --model <model>", "model for this agent (default: global)").option("-a, --avatar <emoji>", "avatar emoji").option("-t, --template <name>", `start from a template: ${Object.keys(templates).join(", ")}`).option("-f, --folder <path...>", "share folder(s) with the agent").option("--rw", "give write access to shared folders").action(agentCreate);
|
|
1838
|
+
agent.command("list").alias("ls").description("list agents").action(agentList);
|
|
1839
|
+
agent.command("rm <name>").description("delete an agent and its memory").action(agentRemove);
|
|
1840
|
+
agent.command("grant <name> <folder>").description("share a folder with an agent").option("--rw", "allow writing").action(agentGrant);
|
|
1841
|
+
agent.command("export <name>").option("-o, --out <file>").description("export agent + memories to JSON").action(agentExport);
|
|
1842
|
+
agent.command("import <file>").description("import an agent from JSON").action(agentImport);
|
|
1843
|
+
program.command("chat <agent>").description("chat with an agent in the terminal").option("-m, --message <text>", "send one message and exit").action(chat);
|
|
1844
|
+
var memory = program.command("memory").description("inspect long-term memory");
|
|
1845
|
+
memory.command("list <agent>", { isDefault: true }).option("-s, --search <query>", "semantic search").option("-n, --limit <n>").action(memoryList);
|
|
1846
|
+
memory.command("add <agent> <text>").option("-t, --type <type>", "fact | decision | preference").action(memoryAdd);
|
|
1847
|
+
var config = program.command("config").description("view or change settings");
|
|
1848
|
+
config.command("list", { isDefault: true }).action(configList);
|
|
1849
|
+
config.command("set <key> <value>").action(configSet);
|
|
1850
|
+
program.command("doctor").description("check your setup").action(doctor);
|
|
1851
|
+
program.parseAsync().catch((err) => {
|
|
1852
|
+
fail(err.message);
|
|
1853
|
+
process.exit(1);
|
|
1854
|
+
});
|