@operor/memory 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/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -0
- package/src/in-memory.ts +79 -0
- package/src/index.ts +3 -0
- package/src/memory.test.ts +243 -0
- package/src/sqlite.ts +190 -0
- package/src/types.ts +2 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +10 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ConversationMessage, Customer, MemoryStore } from "@operor/core";
|
|
2
|
+
|
|
3
|
+
//#region src/in-memory.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* In-memory implementation of MemoryStore.
|
|
6
|
+
* Extracts the existing Map-based behavior from Operor core.
|
|
7
|
+
* Data is lost on restart.
|
|
8
|
+
*/
|
|
9
|
+
declare class InMemoryStore implements MemoryStore {
|
|
10
|
+
private customers;
|
|
11
|
+
private phoneIndex;
|
|
12
|
+
private conversations;
|
|
13
|
+
initialize(): Promise<void>;
|
|
14
|
+
getCustomer(id: string): Promise<Customer | null>;
|
|
15
|
+
getCustomerByPhone(phone: string): Promise<Customer | null>;
|
|
16
|
+
upsertCustomer(customer: Customer): Promise<void>;
|
|
17
|
+
getHistory(customerId: string, limit?: number, agentId?: string): Promise<ConversationMessage[]>;
|
|
18
|
+
addMessage(customerId: string, message: ConversationMessage, agentId?: string): Promise<void>;
|
|
19
|
+
clearHistory(customerId?: string, agentId?: string): Promise<{
|
|
20
|
+
deletedCount: number;
|
|
21
|
+
}>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/sqlite.d.ts
|
|
26
|
+
/**
|
|
27
|
+
* SQLite-backed implementation of MemoryStore.
|
|
28
|
+
* Persists customers and conversation history to a local file.
|
|
29
|
+
*/
|
|
30
|
+
declare class SQLiteMemory implements MemoryStore {
|
|
31
|
+
private db;
|
|
32
|
+
constructor(dbPath?: string);
|
|
33
|
+
initialize(): Promise<void>;
|
|
34
|
+
getCustomer(id: string): Promise<Customer | null>;
|
|
35
|
+
getCustomerByPhone(phone: string): Promise<Customer | null>;
|
|
36
|
+
upsertCustomer(customer: Customer): Promise<void>;
|
|
37
|
+
getHistory(customerId: string, limit?: number, agentId?: string): Promise<ConversationMessage[]>;
|
|
38
|
+
addMessage(customerId: string, message: ConversationMessage, agentId?: string): Promise<void>;
|
|
39
|
+
clearHistory(customerId?: string, agentId?: string): Promise<{
|
|
40
|
+
deletedCount: number;
|
|
41
|
+
}>;
|
|
42
|
+
getSetting(key: string): Promise<string | null>;
|
|
43
|
+
setSetting(key: string, value: string | null): Promise<void>;
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
private rowToCustomer;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { InMemoryStore, type MemoryStore, SQLiteMemory };
|
|
49
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/in-memory.ts","../src/sqlite.ts"],"mappings":";;;;AAQA;;;;cAAa,aAAA,YAAyB,WAAA;EAAA,QAC5B,SAAA;EAAA,QACA,UAAA;EAAA,QACA,aAAA;EAEF,UAAA,CAAA,GAAc,OAAA;EAId,WAAA,CAAY,EAAA,WAAa,OAAA,CAAQ,QAAA;EAIjC,kBAAA,CAAmB,KAAA,WAAgB,OAAA,CAAQ,QAAA;EAM3C,cAAA,CAAe,QAAA,EAAU,QAAA,GAAW,OAAA;EAOpC,UAAA,CAAW,UAAA,UAAoB,KAAA,WAAY,OAAA,YAAmB,OAAA,CAAQ,mBAAA;EAMtE,UAAA,CAAW,UAAA,UAAoB,OAAA,EAAS,mBAAA,EAAqB,OAAA,YAAmB,OAAA;EAchF,YAAA,CAAa,UAAA,WAAqB,OAAA,YAAmB,OAAA;IAAU,YAAA;EAAA;EAmB/D,KAAA,CAAA,GAAS,OAAA;AAAA;;;;AAjEjB;;;cCAa,YAAA,YAAwB,WAAA;EAAA,QAC3B,EAAA;cAEI,MAAA;EAMN,UAAA,CAAA,GAAc,OAAA;EAmDd,WAAA,CAAY,EAAA,WAAa,OAAA,CAAQ,QAAA;EAKjC,kBAAA,CAAmB,KAAA,WAAgB,OAAA,CAAQ,QAAA;EAK3C,cAAA,CAAe,QAAA,EAAU,QAAA,GAAW,OAAA;EA8BpC,UAAA,CAAW,UAAA,UAAoB,KAAA,WAAY,OAAA,YAAmB,OAAA,CAAQ,mBAAA;EAetE,UAAA,CAAW,UAAA,UAAoB,OAAA,EAAS,mBAAA,EAAqB,OAAA,YAAmB,OAAA;EAahF,YAAA,CAAa,UAAA,WAAqB,OAAA,YAAmB,OAAA;IAAU,YAAA;EAAA;EAqB/D,UAAA,CAAW,GAAA,WAAc,OAAA;EAKzB,UAAA,CAAW,GAAA,UAAa,KAAA,kBAAuB,OAAA;EAQ/C,KAAA,CAAA,GAAS,OAAA;EAAA,QAIP,aAAA;AAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
//#region src/in-memory.ts
|
|
4
|
+
/**
|
|
5
|
+
* In-memory implementation of MemoryStore.
|
|
6
|
+
* Extracts the existing Map-based behavior from Operor core.
|
|
7
|
+
* Data is lost on restart.
|
|
8
|
+
*/
|
|
9
|
+
var InMemoryStore = class {
|
|
10
|
+
customers = /* @__PURE__ */ new Map();
|
|
11
|
+
phoneIndex = /* @__PURE__ */ new Map();
|
|
12
|
+
conversations = /* @__PURE__ */ new Map();
|
|
13
|
+
async initialize() {}
|
|
14
|
+
async getCustomer(id) {
|
|
15
|
+
return this.customers.get(id) || null;
|
|
16
|
+
}
|
|
17
|
+
async getCustomerByPhone(phone) {
|
|
18
|
+
const customerId = this.phoneIndex.get(phone);
|
|
19
|
+
if (!customerId) return null;
|
|
20
|
+
return this.customers.get(customerId) || null;
|
|
21
|
+
}
|
|
22
|
+
async upsertCustomer(customer) {
|
|
23
|
+
this.customers.set(customer.id, customer);
|
|
24
|
+
if (customer.phone) this.phoneIndex.set(customer.phone, customer.id);
|
|
25
|
+
}
|
|
26
|
+
async getHistory(customerId, limit = 50, agentId) {
|
|
27
|
+
const key = agentId ? `${customerId}:${agentId}` : customerId;
|
|
28
|
+
return (this.conversations.get(key) || []).slice(-limit);
|
|
29
|
+
}
|
|
30
|
+
async addMessage(customerId, message, agentId) {
|
|
31
|
+
const key = agentId ? `${customerId}:${agentId}` : customerId;
|
|
32
|
+
if (!this.conversations.has(key)) this.conversations.set(key, []);
|
|
33
|
+
const history = this.conversations.get(key);
|
|
34
|
+
history.push(message);
|
|
35
|
+
if (history.length > 50) history.splice(0, history.length - 50);
|
|
36
|
+
}
|
|
37
|
+
async clearHistory(customerId, agentId) {
|
|
38
|
+
let deletedCount = 0;
|
|
39
|
+
if (customerId) for (const [key, messages] of this.conversations) {
|
|
40
|
+
const [cid, aid] = key.includes(":") ? key.split(":") : [key, void 0];
|
|
41
|
+
if (cid === customerId && (!agentId || aid === agentId)) {
|
|
42
|
+
deletedCount += messages.length;
|
|
43
|
+
this.conversations.delete(key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
for (const messages of this.conversations.values()) deletedCount += messages.length;
|
|
48
|
+
this.conversations.clear();
|
|
49
|
+
}
|
|
50
|
+
return { deletedCount };
|
|
51
|
+
}
|
|
52
|
+
async close() {
|
|
53
|
+
this.customers.clear();
|
|
54
|
+
this.phoneIndex.clear();
|
|
55
|
+
this.conversations.clear();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/sqlite.ts
|
|
61
|
+
/**
|
|
62
|
+
* SQLite-backed implementation of MemoryStore.
|
|
63
|
+
* Persists customers and conversation history to a local file.
|
|
64
|
+
*/
|
|
65
|
+
var SQLiteMemory = class {
|
|
66
|
+
db;
|
|
67
|
+
constructor(dbPath = "./operor.db") {
|
|
68
|
+
this.db = new Database(dbPath);
|
|
69
|
+
this.db.pragma("journal_mode = WAL");
|
|
70
|
+
this.db.pragma("foreign_keys = ON");
|
|
71
|
+
}
|
|
72
|
+
async initialize() {
|
|
73
|
+
this.db.exec(`
|
|
74
|
+
CREATE TABLE IF NOT EXISTS customers (
|
|
75
|
+
id TEXT PRIMARY KEY,
|
|
76
|
+
phone TEXT,
|
|
77
|
+
email TEXT,
|
|
78
|
+
name TEXT,
|
|
79
|
+
whatsapp_id TEXT,
|
|
80
|
+
instagram_id TEXT,
|
|
81
|
+
facebook_id TEXT,
|
|
82
|
+
metadata TEXT,
|
|
83
|
+
lifetime_value REAL,
|
|
84
|
+
first_interaction INTEGER,
|
|
85
|
+
last_interaction INTEGER,
|
|
86
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
87
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
88
|
+
);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_customers_phone ON customers(phone);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
92
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
93
|
+
customer_id TEXT NOT NULL,
|
|
94
|
+
role TEXT NOT NULL,
|
|
95
|
+
content TEXT NOT NULL,
|
|
96
|
+
tool_calls TEXT,
|
|
97
|
+
agent_id TEXT,
|
|
98
|
+
timestamp INTEGER NOT NULL,
|
|
99
|
+
FOREIGN KEY (customer_id) REFERENCES customers(id)
|
|
100
|
+
);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_messages_customer ON messages(customer_id);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(customer_id, timestamp);
|
|
103
|
+
`);
|
|
104
|
+
if (!this.db.prepare("PRAGMA table_info(messages)").all().some((c) => c.name === "agent_id")) this.db.exec("ALTER TABLE messages ADD COLUMN agent_id TEXT");
|
|
105
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(customer_id, agent_id)");
|
|
106
|
+
this.db.exec(`
|
|
107
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
108
|
+
key TEXT PRIMARY KEY,
|
|
109
|
+
value TEXT NOT NULL
|
|
110
|
+
);
|
|
111
|
+
`);
|
|
112
|
+
}
|
|
113
|
+
async getCustomer(id) {
|
|
114
|
+
const row = this.db.prepare("SELECT * FROM customers WHERE id = ?").get(id);
|
|
115
|
+
return row ? this.rowToCustomer(row) : null;
|
|
116
|
+
}
|
|
117
|
+
async getCustomerByPhone(phone) {
|
|
118
|
+
const row = this.db.prepare("SELECT * FROM customers WHERE phone = ?").get(phone);
|
|
119
|
+
return row ? this.rowToCustomer(row) : null;
|
|
120
|
+
}
|
|
121
|
+
async upsertCustomer(customer) {
|
|
122
|
+
this.db.prepare(`
|
|
123
|
+
INSERT INTO customers (id, phone, email, name, whatsapp_id, instagram_id, facebook_id, metadata, lifetime_value, first_interaction, last_interaction, updated_at)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())
|
|
125
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
126
|
+
phone = excluded.phone,
|
|
127
|
+
email = excluded.email,
|
|
128
|
+
name = excluded.name,
|
|
129
|
+
whatsapp_id = excluded.whatsapp_id,
|
|
130
|
+
instagram_id = excluded.instagram_id,
|
|
131
|
+
facebook_id = excluded.facebook_id,
|
|
132
|
+
metadata = excluded.metadata,
|
|
133
|
+
lifetime_value = excluded.lifetime_value,
|
|
134
|
+
last_interaction = excluded.last_interaction,
|
|
135
|
+
updated_at = unixepoch()
|
|
136
|
+
`).run(customer.id, customer.phone || null, customer.email || null, customer.name || null, customer.whatsappId || null, customer.instagramId || null, customer.facebookId || null, customer.metadata ? JSON.stringify(customer.metadata) : null, customer.lifetimeValue || null, customer.firstInteraction ? customer.firstInteraction.getTime() : null, customer.lastInteraction ? customer.lastInteraction.getTime() : null);
|
|
137
|
+
}
|
|
138
|
+
async getHistory(customerId, limit = 50, agentId) {
|
|
139
|
+
const query = agentId ? "SELECT role, content, tool_calls, timestamp FROM messages WHERE customer_id = ? AND agent_id = ? ORDER BY timestamp DESC LIMIT ?" : "SELECT role, content, tool_calls, timestamp FROM messages WHERE customer_id = ? ORDER BY timestamp DESC LIMIT ?";
|
|
140
|
+
const params = agentId ? [
|
|
141
|
+
customerId,
|
|
142
|
+
agentId,
|
|
143
|
+
limit
|
|
144
|
+
] : [customerId, limit];
|
|
145
|
+
return this.db.prepare(query).all(...params).reverse().map((row) => ({
|
|
146
|
+
role: row.role,
|
|
147
|
+
content: row.content,
|
|
148
|
+
timestamp: row.timestamp,
|
|
149
|
+
toolCalls: row.tool_calls ? JSON.parse(row.tool_calls) : void 0
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
async addMessage(customerId, message, agentId) {
|
|
153
|
+
this.db.prepare("INSERT INTO messages (customer_id, role, content, tool_calls, agent_id, timestamp) VALUES (?, ?, ?, ?, ?, ?)").run(customerId, message.role, message.content, message.toolCalls ? JSON.stringify(message.toolCalls) : null, agentId || null, message.timestamp);
|
|
154
|
+
}
|
|
155
|
+
async clearHistory(customerId, agentId) {
|
|
156
|
+
let query = "DELETE FROM messages";
|
|
157
|
+
const conditions = [];
|
|
158
|
+
const params = [];
|
|
159
|
+
if (customerId) {
|
|
160
|
+
conditions.push("customer_id = ?");
|
|
161
|
+
params.push(customerId);
|
|
162
|
+
}
|
|
163
|
+
if (agentId) {
|
|
164
|
+
conditions.push("agent_id = ?");
|
|
165
|
+
params.push(agentId);
|
|
166
|
+
}
|
|
167
|
+
if (conditions.length > 0) query += " WHERE " + conditions.join(" AND ");
|
|
168
|
+
return { deletedCount: this.db.prepare(query).run(...params).changes };
|
|
169
|
+
}
|
|
170
|
+
async getSetting(key) {
|
|
171
|
+
return this.db.prepare("SELECT value FROM settings WHERE key = ?").get(key)?.value ?? null;
|
|
172
|
+
}
|
|
173
|
+
async setSetting(key, value) {
|
|
174
|
+
if (value === null) this.db.prepare("DELETE FROM settings WHERE key = ?").run(key);
|
|
175
|
+
else this.db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
176
|
+
}
|
|
177
|
+
async close() {
|
|
178
|
+
this.db.close();
|
|
179
|
+
}
|
|
180
|
+
rowToCustomer(row) {
|
|
181
|
+
return {
|
|
182
|
+
id: row.id,
|
|
183
|
+
phone: row.phone || void 0,
|
|
184
|
+
email: row.email || void 0,
|
|
185
|
+
name: row.name || void 0,
|
|
186
|
+
whatsappId: row.whatsapp_id || void 0,
|
|
187
|
+
instagramId: row.instagram_id || void 0,
|
|
188
|
+
facebookId: row.facebook_id || void 0,
|
|
189
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : void 0,
|
|
190
|
+
lifetimeValue: row.lifetime_value || void 0,
|
|
191
|
+
firstInteraction: row.first_interaction ? new Date(row.first_interaction) : void 0,
|
|
192
|
+
lastInteraction: row.last_interaction ? new Date(row.last_interaction) : void 0
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
//#endregion
|
|
198
|
+
export { InMemoryStore, SQLiteMemory };
|
|
199
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/in-memory.ts","../src/sqlite.ts"],"sourcesContent":["import type { Customer, ConversationMessage } from '@operor/core';\nimport type { MemoryStore } from './types.js';\n\n/**\n * In-memory implementation of MemoryStore.\n * Extracts the existing Map-based behavior from Operor core.\n * Data is lost on restart.\n */\nexport class InMemoryStore implements MemoryStore {\n private customers: Map<string, Customer> = new Map();\n private phoneIndex: Map<string, string> = new Map(); // phone -> customerId\n private conversations: Map<string, ConversationMessage[]> = new Map();\n\n async initialize(): Promise<void> {\n // Nothing to initialize for in-memory store\n }\n\n async getCustomer(id: string): Promise<Customer | null> {\n return this.customers.get(id) || null;\n }\n\n async getCustomerByPhone(phone: string): Promise<Customer | null> {\n const customerId = this.phoneIndex.get(phone);\n if (!customerId) return null;\n return this.customers.get(customerId) || null;\n }\n\n async upsertCustomer(customer: Customer): Promise<void> {\n this.customers.set(customer.id, customer);\n if (customer.phone) {\n this.phoneIndex.set(customer.phone, customer.id);\n }\n }\n\n async getHistory(customerId: string, limit = 50, agentId?: string): Promise<ConversationMessage[]> {\n const key = agentId ? `${customerId}:${agentId}` : customerId;\n const history = this.conversations.get(key) || [];\n return history.slice(-limit);\n }\n\n async addMessage(customerId: string, message: ConversationMessage, agentId?: string): Promise<void> {\n const key = agentId ? `${customerId}:${agentId}` : customerId;\n if (!this.conversations.has(key)) {\n this.conversations.set(key, []);\n }\n const history = this.conversations.get(key)!;\n history.push(message);\n\n // Keep only last 50 messages\n if (history.length > 50) {\n history.splice(0, history.length - 50);\n }\n }\n\n async clearHistory(customerId?: string, agentId?: string): Promise<{ deletedCount: number }> {\n let deletedCount = 0;\n if (customerId) {\n for (const [key, messages] of this.conversations) {\n const [cid, aid] = key.includes(':') ? key.split(':') : [key, undefined];\n if (cid === customerId && (!agentId || aid === agentId)) {\n deletedCount += messages.length;\n this.conversations.delete(key);\n }\n }\n } else {\n for (const messages of this.conversations.values()) {\n deletedCount += messages.length;\n }\n this.conversations.clear();\n }\n return { deletedCount };\n }\n\n async close(): Promise<void> {\n this.customers.clear();\n this.phoneIndex.clear();\n this.conversations.clear();\n }\n}\n","import Database from 'better-sqlite3';\nimport type { Customer, ConversationMessage } from '@operor/core';\nimport type { MemoryStore } from './types.js';\n\n/**\n * SQLite-backed implementation of MemoryStore.\n * Persists customers and conversation history to a local file.\n */\nexport class SQLiteMemory implements MemoryStore {\n private db: Database.Database;\n\n constructor(dbPath: string = './operor.db') {\n this.db = new Database(dbPath);\n this.db.pragma('journal_mode = WAL');\n this.db.pragma('foreign_keys = ON');\n }\n\n async initialize(): Promise<void> {\n this.db.exec(`\n CREATE TABLE IF NOT EXISTS customers (\n id TEXT PRIMARY KEY,\n phone TEXT,\n email TEXT,\n name TEXT,\n whatsapp_id TEXT,\n instagram_id TEXT,\n facebook_id TEXT,\n metadata TEXT,\n lifetime_value REAL,\n first_interaction INTEGER,\n last_interaction INTEGER,\n created_at INTEGER DEFAULT (unixepoch()),\n updated_at INTEGER DEFAULT (unixepoch())\n );\n CREATE INDEX IF NOT EXISTS idx_customers_phone ON customers(phone);\n\n CREATE TABLE IF NOT EXISTS messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n customer_id TEXT NOT NULL,\n role TEXT NOT NULL,\n content TEXT NOT NULL,\n tool_calls TEXT,\n agent_id TEXT,\n timestamp INTEGER NOT NULL,\n FOREIGN KEY (customer_id) REFERENCES customers(id)\n );\n CREATE INDEX IF NOT EXISTS idx_messages_customer ON messages(customer_id);\n CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(customer_id, timestamp);\n `);\n\n // Migration: add agent_id column if missing (existing databases)\n const columns = this.db.prepare(\"PRAGMA table_info(messages)\").all() as any[];\n if (!columns.some((c: any) => c.name === 'agent_id')) {\n this.db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');\n }\n\n // Create agent_id index after migration ensures the column exists\n this.db.exec('CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(customer_id, agent_id)');\n\n // Settings table for key-value storage (whitelist, etc.)\n this.db.exec(`\n CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n `);\n }\n\n async getCustomer(id: string): Promise<Customer | null> {\n const row = this.db.prepare('SELECT * FROM customers WHERE id = ?').get(id) as any;\n return row ? this.rowToCustomer(row) : null;\n }\n\n async getCustomerByPhone(phone: string): Promise<Customer | null> {\n const row = this.db.prepare('SELECT * FROM customers WHERE phone = ?').get(phone) as any;\n return row ? this.rowToCustomer(row) : null;\n }\n\n async upsertCustomer(customer: Customer): Promise<void> {\n this.db.prepare(`\n INSERT INTO customers (id, phone, email, name, whatsapp_id, instagram_id, facebook_id, metadata, lifetime_value, first_interaction, last_interaction, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())\n ON CONFLICT(id) DO UPDATE SET\n phone = excluded.phone,\n email = excluded.email,\n name = excluded.name,\n whatsapp_id = excluded.whatsapp_id,\n instagram_id = excluded.instagram_id,\n facebook_id = excluded.facebook_id,\n metadata = excluded.metadata,\n lifetime_value = excluded.lifetime_value,\n last_interaction = excluded.last_interaction,\n updated_at = unixepoch()\n `).run(\n customer.id,\n customer.phone || null,\n customer.email || null,\n customer.name || null,\n customer.whatsappId || null,\n customer.instagramId || null,\n customer.facebookId || null,\n customer.metadata ? JSON.stringify(customer.metadata) : null,\n customer.lifetimeValue || null,\n customer.firstInteraction ? customer.firstInteraction.getTime() : null,\n customer.lastInteraction ? customer.lastInteraction.getTime() : null,\n );\n }\n\n async getHistory(customerId: string, limit = 50, agentId?: string): Promise<ConversationMessage[]> {\n const query = agentId\n ? 'SELECT role, content, tool_calls, timestamp FROM messages WHERE customer_id = ? AND agent_id = ? ORDER BY timestamp DESC LIMIT ?'\n : 'SELECT role, content, tool_calls, timestamp FROM messages WHERE customer_id = ? ORDER BY timestamp DESC LIMIT ?';\n const params = agentId ? [customerId, agentId, limit] : [customerId, limit];\n const rows = this.db.prepare(query).all(...params) as any[];\n\n return rows.reverse().map(row => ({\n role: row.role,\n content: row.content,\n timestamp: row.timestamp,\n toolCalls: row.tool_calls ? JSON.parse(row.tool_calls) : undefined,\n }));\n }\n\n async addMessage(customerId: string, message: ConversationMessage, agentId?: string): Promise<void> {\n this.db.prepare(\n 'INSERT INTO messages (customer_id, role, content, tool_calls, agent_id, timestamp) VALUES (?, ?, ?, ?, ?, ?)'\n ).run(\n customerId,\n message.role,\n message.content,\n message.toolCalls ? JSON.stringify(message.toolCalls) : null,\n agentId || null,\n message.timestamp,\n );\n }\n\n async clearHistory(customerId?: string, agentId?: string): Promise<{ deletedCount: number }> {\n let query = 'DELETE FROM messages';\n const conditions: string[] = [];\n const params: any[] = [];\n\n if (customerId) {\n conditions.push('customer_id = ?');\n params.push(customerId);\n }\n if (agentId) {\n conditions.push('agent_id = ?');\n params.push(agentId);\n }\n if (conditions.length > 0) {\n query += ' WHERE ' + conditions.join(' AND ');\n }\n\n const result = this.db.prepare(query).run(...params);\n return { deletedCount: result.changes };\n }\n\n async getSetting(key: string): Promise<string | null> {\n const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;\n return row?.value ?? null;\n }\n\n async setSetting(key: string, value: string | null): Promise<void> {\n if (value === null) {\n this.db.prepare('DELETE FROM settings WHERE key = ?').run(key);\n } else {\n this.db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);\n }\n }\n\n async close(): Promise<void> {\n this.db.close();\n }\n\n private rowToCustomer(row: any): Customer {\n return {\n id: row.id,\n phone: row.phone || undefined,\n email: row.email || undefined,\n name: row.name || undefined,\n whatsappId: row.whatsapp_id || undefined,\n instagramId: row.instagram_id || undefined,\n facebookId: row.facebook_id || undefined,\n metadata: row.metadata ? JSON.parse(row.metadata) : undefined,\n lifetimeValue: row.lifetime_value || undefined,\n firstInteraction: row.first_interaction ? new Date(row.first_interaction) : undefined,\n lastInteraction: row.last_interaction ? new Date(row.last_interaction) : undefined,\n };\n }\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@operor/memory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Memory layer for Agent OS with SQLite and in-memory implementations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"better-sqlite3": "^12.0.0",
|
|
16
|
+
"@operor/core": "0.1.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"tsdown": "^0.20.3",
|
|
22
|
+
"typescript": "^5.7.0",
|
|
23
|
+
"vitest": "^4.0.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsdown",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/in-memory.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Customer, ConversationMessage } from '@operor/core';
|
|
2
|
+
import type { MemoryStore } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory implementation of MemoryStore.
|
|
6
|
+
* Extracts the existing Map-based behavior from Operor core.
|
|
7
|
+
* Data is lost on restart.
|
|
8
|
+
*/
|
|
9
|
+
export class InMemoryStore implements MemoryStore {
|
|
10
|
+
private customers: Map<string, Customer> = new Map();
|
|
11
|
+
private phoneIndex: Map<string, string> = new Map(); // phone -> customerId
|
|
12
|
+
private conversations: Map<string, ConversationMessage[]> = new Map();
|
|
13
|
+
|
|
14
|
+
async initialize(): Promise<void> {
|
|
15
|
+
// Nothing to initialize for in-memory store
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getCustomer(id: string): Promise<Customer | null> {
|
|
19
|
+
return this.customers.get(id) || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async getCustomerByPhone(phone: string): Promise<Customer | null> {
|
|
23
|
+
const customerId = this.phoneIndex.get(phone);
|
|
24
|
+
if (!customerId) return null;
|
|
25
|
+
return this.customers.get(customerId) || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async upsertCustomer(customer: Customer): Promise<void> {
|
|
29
|
+
this.customers.set(customer.id, customer);
|
|
30
|
+
if (customer.phone) {
|
|
31
|
+
this.phoneIndex.set(customer.phone, customer.id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async getHistory(customerId: string, limit = 50, agentId?: string): Promise<ConversationMessage[]> {
|
|
36
|
+
const key = agentId ? `${customerId}:${agentId}` : customerId;
|
|
37
|
+
const history = this.conversations.get(key) || [];
|
|
38
|
+
return history.slice(-limit);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async addMessage(customerId: string, message: ConversationMessage, agentId?: string): Promise<void> {
|
|
42
|
+
const key = agentId ? `${customerId}:${agentId}` : customerId;
|
|
43
|
+
if (!this.conversations.has(key)) {
|
|
44
|
+
this.conversations.set(key, []);
|
|
45
|
+
}
|
|
46
|
+
const history = this.conversations.get(key)!;
|
|
47
|
+
history.push(message);
|
|
48
|
+
|
|
49
|
+
// Keep only last 50 messages
|
|
50
|
+
if (history.length > 50) {
|
|
51
|
+
history.splice(0, history.length - 50);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async clearHistory(customerId?: string, agentId?: string): Promise<{ deletedCount: number }> {
|
|
56
|
+
let deletedCount = 0;
|
|
57
|
+
if (customerId) {
|
|
58
|
+
for (const [key, messages] of this.conversations) {
|
|
59
|
+
const [cid, aid] = key.includes(':') ? key.split(':') : [key, undefined];
|
|
60
|
+
if (cid === customerId && (!agentId || aid === agentId)) {
|
|
61
|
+
deletedCount += messages.length;
|
|
62
|
+
this.conversations.delete(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
for (const messages of this.conversations.values()) {
|
|
67
|
+
deletedCount += messages.length;
|
|
68
|
+
}
|
|
69
|
+
this.conversations.clear();
|
|
70
|
+
}
|
|
71
|
+
return { deletedCount };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async close(): Promise<void> {
|
|
75
|
+
this.customers.clear();
|
|
76
|
+
this.phoneIndex.clear();
|
|
77
|
+
this.conversations.clear();
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { InMemoryStore } from '../src/in-memory.js';
|
|
3
|
+
import { SQLiteMemory } from '../src/sqlite.js';
|
|
4
|
+
import type { Customer, ConversationMessage } from '@operor/core';
|
|
5
|
+
import { unlinkSync } from 'fs';
|
|
6
|
+
|
|
7
|
+
const testDbPath = './test-operor.db';
|
|
8
|
+
|
|
9
|
+
describe('MemoryStore implementations', () => {
|
|
10
|
+
describe('InMemoryStore', () => {
|
|
11
|
+
let store: InMemoryStore;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
store = new InMemoryStore();
|
|
15
|
+
await store.initialize();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await store.close();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should store and retrieve customers', async () => {
|
|
23
|
+
const customer: Customer = {
|
|
24
|
+
id: 'cust_1',
|
|
25
|
+
phone: '+1234567890',
|
|
26
|
+
name: 'Test User',
|
|
27
|
+
firstInteraction: new Date(),
|
|
28
|
+
lastInteraction: new Date(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
await store.upsertCustomer(customer);
|
|
32
|
+
const retrieved = await store.getCustomer('cust_1');
|
|
33
|
+
|
|
34
|
+
expect(retrieved).toEqual(customer);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should retrieve customer by phone', async () => {
|
|
38
|
+
const customer: Customer = {
|
|
39
|
+
id: 'cust_2',
|
|
40
|
+
phone: '+9876543210',
|
|
41
|
+
name: 'Phone User',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
await store.upsertCustomer(customer);
|
|
45
|
+
const retrieved = await store.getCustomerByPhone('+9876543210');
|
|
46
|
+
|
|
47
|
+
expect(retrieved?.id).toBe('cust_2');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should update existing customer', async () => {
|
|
51
|
+
const customer: Customer = {
|
|
52
|
+
id: 'cust_3',
|
|
53
|
+
phone: '+1111111111',
|
|
54
|
+
name: 'Original Name',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
await store.upsertCustomer(customer);
|
|
58
|
+
|
|
59
|
+
customer.name = 'Updated Name';
|
|
60
|
+
await store.upsertCustomer(customer);
|
|
61
|
+
|
|
62
|
+
const retrieved = await store.getCustomer('cust_3');
|
|
63
|
+
expect(retrieved?.name).toBe('Updated Name');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should store and retrieve conversation history', async () => {
|
|
67
|
+
const customerId = 'cust_4';
|
|
68
|
+
const messages: ConversationMessage[] = [
|
|
69
|
+
{ role: 'user', content: 'Hello', timestamp: Date.now() },
|
|
70
|
+
{ role: 'assistant', content: 'Hi there!', timestamp: Date.now() + 1000 },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
for (const msg of messages) {
|
|
74
|
+
await store.addMessage(customerId, msg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const history = await store.getHistory(customerId);
|
|
78
|
+
expect(history).toHaveLength(2);
|
|
79
|
+
expect(history[0].content).toBe('Hello');
|
|
80
|
+
expect(history[1].content).toBe('Hi there!');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should limit history to specified number', async () => {
|
|
84
|
+
const customerId = 'cust_5';
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
await store.addMessage(customerId, {
|
|
88
|
+
role: 'user',
|
|
89
|
+
content: `Message ${i}`,
|
|
90
|
+
timestamp: Date.now() + i,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const history = await store.getHistory(customerId, 5);
|
|
95
|
+
expect(history).toHaveLength(5);
|
|
96
|
+
expect(history[0].content).toBe('Message 5');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('SQLiteMemory', () => {
|
|
101
|
+
let store: SQLiteMemory;
|
|
102
|
+
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
// Clean up any existing test database
|
|
105
|
+
try {
|
|
106
|
+
unlinkSync(testDbPath);
|
|
107
|
+
} catch {}
|
|
108
|
+
|
|
109
|
+
store = new SQLiteMemory(testDbPath);
|
|
110
|
+
await store.initialize();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
await store.close();
|
|
115
|
+
try {
|
|
116
|
+
unlinkSync(testDbPath);
|
|
117
|
+
} catch {}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should store and retrieve customers', async () => {
|
|
121
|
+
const customer: Customer = {
|
|
122
|
+
id: 'cust_1',
|
|
123
|
+
phone: '+1234567890',
|
|
124
|
+
name: 'Test User',
|
|
125
|
+
email: 'test@example.com',
|
|
126
|
+
whatsappId: 'wa_123',
|
|
127
|
+
metadata: { source: 'test' },
|
|
128
|
+
lifetimeValue: 100.50,
|
|
129
|
+
firstInteraction: new Date(),
|
|
130
|
+
lastInteraction: new Date(),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await store.upsertCustomer(customer);
|
|
134
|
+
const retrieved = await store.getCustomer('cust_1');
|
|
135
|
+
|
|
136
|
+
expect(retrieved).toBeDefined();
|
|
137
|
+
expect(retrieved?.id).toBe('cust_1');
|
|
138
|
+
expect(retrieved?.phone).toBe('+1234567890');
|
|
139
|
+
expect(retrieved?.name).toBe('Test User');
|
|
140
|
+
expect(retrieved?.email).toBe('test@example.com');
|
|
141
|
+
expect(retrieved?.whatsappId).toBe('wa_123');
|
|
142
|
+
expect(retrieved?.metadata).toEqual({ source: 'test' });
|
|
143
|
+
expect(retrieved?.lifetimeValue).toBe(100.50);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should retrieve customer by phone', async () => {
|
|
147
|
+
const customer: Customer = {
|
|
148
|
+
id: 'cust_2',
|
|
149
|
+
phone: '+9876543210',
|
|
150
|
+
name: 'Phone User',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
await store.upsertCustomer(customer);
|
|
154
|
+
const retrieved = await store.getCustomerByPhone('+9876543210');
|
|
155
|
+
|
|
156
|
+
expect(retrieved?.id).toBe('cust_2');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should update existing customer', async () => {
|
|
160
|
+
const customer: Customer = {
|
|
161
|
+
id: 'cust_3',
|
|
162
|
+
phone: '+1111111111',
|
|
163
|
+
name: 'Original Name',
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
await store.upsertCustomer(customer);
|
|
167
|
+
|
|
168
|
+
customer.name = 'Updated Name';
|
|
169
|
+
customer.email = 'updated@example.com';
|
|
170
|
+
await store.upsertCustomer(customer);
|
|
171
|
+
|
|
172
|
+
const retrieved = await store.getCustomer('cust_3');
|
|
173
|
+
expect(retrieved?.name).toBe('Updated Name');
|
|
174
|
+
expect(retrieved?.email).toBe('updated@example.com');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should store and retrieve conversation history', async () => {
|
|
178
|
+
const customerId = 'cust_4';
|
|
179
|
+
await store.upsertCustomer({ id: customerId, phone: '+4444444444' });
|
|
180
|
+
|
|
181
|
+
const messages: ConversationMessage[] = [
|
|
182
|
+
{ role: 'user', content: 'Hello', timestamp: Date.now() },
|
|
183
|
+
{ role: 'assistant', content: 'Hi there!', timestamp: Date.now() + 1000, toolCalls: [] },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
for (const msg of messages) {
|
|
187
|
+
await store.addMessage(customerId, msg);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const history = await store.getHistory(customerId);
|
|
191
|
+
expect(history).toHaveLength(2);
|
|
192
|
+
expect(history[0].content).toBe('Hello');
|
|
193
|
+
expect(history[1].content).toBe('Hi there!');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should limit history to specified number', async () => {
|
|
197
|
+
const customerId = 'cust_5';
|
|
198
|
+
await store.upsertCustomer({ id: customerId, phone: '+5555555555' });
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i < 10; i++) {
|
|
201
|
+
await store.addMessage(customerId, {
|
|
202
|
+
role: 'user',
|
|
203
|
+
content: `Message ${i}`,
|
|
204
|
+
timestamp: Date.now() + i,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const history = await store.getHistory(customerId, 5);
|
|
209
|
+
expect(history).toHaveLength(5);
|
|
210
|
+
expect(history[0].content).toBe('Message 5');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should persist data across instances', async () => {
|
|
214
|
+
const customer: Customer = {
|
|
215
|
+
id: 'cust_persist',
|
|
216
|
+
phone: '+5555555555',
|
|
217
|
+
name: 'Persistent User',
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
await store.upsertCustomer(customer);
|
|
221
|
+
await store.addMessage('cust_persist', {
|
|
222
|
+
role: 'user',
|
|
223
|
+
content: 'Persistent message',
|
|
224
|
+
timestamp: Date.now(),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await store.close();
|
|
228
|
+
|
|
229
|
+
// Create new instance with same database
|
|
230
|
+
const store2 = new SQLiteMemory(testDbPath);
|
|
231
|
+
await store2.initialize();
|
|
232
|
+
|
|
233
|
+
const retrieved = await store2.getCustomer('cust_persist');
|
|
234
|
+
expect(retrieved?.name).toBe('Persistent User');
|
|
235
|
+
|
|
236
|
+
const history = await store2.getHistory('cust_persist');
|
|
237
|
+
expect(history).toHaveLength(1);
|
|
238
|
+
expect(history[0].content).toBe('Persistent message');
|
|
239
|
+
|
|
240
|
+
await store2.close();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
package/src/sqlite.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import type { Customer, ConversationMessage } from '@operor/core';
|
|
3
|
+
import type { MemoryStore } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SQLite-backed implementation of MemoryStore.
|
|
7
|
+
* Persists customers and conversation history to a local file.
|
|
8
|
+
*/
|
|
9
|
+
export class SQLiteMemory implements MemoryStore {
|
|
10
|
+
private db: Database.Database;
|
|
11
|
+
|
|
12
|
+
constructor(dbPath: string = './operor.db') {
|
|
13
|
+
this.db = new Database(dbPath);
|
|
14
|
+
this.db.pragma('journal_mode = WAL');
|
|
15
|
+
this.db.pragma('foreign_keys = ON');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async initialize(): Promise<void> {
|
|
19
|
+
this.db.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS customers (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
phone TEXT,
|
|
23
|
+
email TEXT,
|
|
24
|
+
name TEXT,
|
|
25
|
+
whatsapp_id TEXT,
|
|
26
|
+
instagram_id TEXT,
|
|
27
|
+
facebook_id TEXT,
|
|
28
|
+
metadata TEXT,
|
|
29
|
+
lifetime_value REAL,
|
|
30
|
+
first_interaction INTEGER,
|
|
31
|
+
last_interaction INTEGER,
|
|
32
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
33
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_customers_phone ON customers(phone);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
customer_id TEXT NOT NULL,
|
|
40
|
+
role TEXT NOT NULL,
|
|
41
|
+
content TEXT NOT NULL,
|
|
42
|
+
tool_calls TEXT,
|
|
43
|
+
agent_id TEXT,
|
|
44
|
+
timestamp INTEGER NOT NULL,
|
|
45
|
+
FOREIGN KEY (customer_id) REFERENCES customers(id)
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_messages_customer ON messages(customer_id);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(customer_id, timestamp);
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
// Migration: add agent_id column if missing (existing databases)
|
|
52
|
+
const columns = this.db.prepare("PRAGMA table_info(messages)").all() as any[];
|
|
53
|
+
if (!columns.some((c: any) => c.name === 'agent_id')) {
|
|
54
|
+
this.db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create agent_id index after migration ensures the column exists
|
|
58
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(customer_id, agent_id)');
|
|
59
|
+
|
|
60
|
+
// Settings table for key-value storage (whitelist, etc.)
|
|
61
|
+
this.db.exec(`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
63
|
+
key TEXT PRIMARY KEY,
|
|
64
|
+
value TEXT NOT NULL
|
|
65
|
+
);
|
|
66
|
+
`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async getCustomer(id: string): Promise<Customer | null> {
|
|
70
|
+
const row = this.db.prepare('SELECT * FROM customers WHERE id = ?').get(id) as any;
|
|
71
|
+
return row ? this.rowToCustomer(row) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getCustomerByPhone(phone: string): Promise<Customer | null> {
|
|
75
|
+
const row = this.db.prepare('SELECT * FROM customers WHERE phone = ?').get(phone) as any;
|
|
76
|
+
return row ? this.rowToCustomer(row) : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async upsertCustomer(customer: Customer): Promise<void> {
|
|
80
|
+
this.db.prepare(`
|
|
81
|
+
INSERT INTO customers (id, phone, email, name, whatsapp_id, instagram_id, facebook_id, metadata, lifetime_value, first_interaction, last_interaction, updated_at)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())
|
|
83
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
84
|
+
phone = excluded.phone,
|
|
85
|
+
email = excluded.email,
|
|
86
|
+
name = excluded.name,
|
|
87
|
+
whatsapp_id = excluded.whatsapp_id,
|
|
88
|
+
instagram_id = excluded.instagram_id,
|
|
89
|
+
facebook_id = excluded.facebook_id,
|
|
90
|
+
metadata = excluded.metadata,
|
|
91
|
+
lifetime_value = excluded.lifetime_value,
|
|
92
|
+
last_interaction = excluded.last_interaction,
|
|
93
|
+
updated_at = unixepoch()
|
|
94
|
+
`).run(
|
|
95
|
+
customer.id,
|
|
96
|
+
customer.phone || null,
|
|
97
|
+
customer.email || null,
|
|
98
|
+
customer.name || null,
|
|
99
|
+
customer.whatsappId || null,
|
|
100
|
+
customer.instagramId || null,
|
|
101
|
+
customer.facebookId || null,
|
|
102
|
+
customer.metadata ? JSON.stringify(customer.metadata) : null,
|
|
103
|
+
customer.lifetimeValue || null,
|
|
104
|
+
customer.firstInteraction ? customer.firstInteraction.getTime() : null,
|
|
105
|
+
customer.lastInteraction ? customer.lastInteraction.getTime() : null,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async getHistory(customerId: string, limit = 50, agentId?: string): Promise<ConversationMessage[]> {
|
|
110
|
+
const query = agentId
|
|
111
|
+
? 'SELECT role, content, tool_calls, timestamp FROM messages WHERE customer_id = ? AND agent_id = ? ORDER BY timestamp DESC LIMIT ?'
|
|
112
|
+
: 'SELECT role, content, tool_calls, timestamp FROM messages WHERE customer_id = ? ORDER BY timestamp DESC LIMIT ?';
|
|
113
|
+
const params = agentId ? [customerId, agentId, limit] : [customerId, limit];
|
|
114
|
+
const rows = this.db.prepare(query).all(...params) as any[];
|
|
115
|
+
|
|
116
|
+
return rows.reverse().map(row => ({
|
|
117
|
+
role: row.role,
|
|
118
|
+
content: row.content,
|
|
119
|
+
timestamp: row.timestamp,
|
|
120
|
+
toolCalls: row.tool_calls ? JSON.parse(row.tool_calls) : undefined,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async addMessage(customerId: string, message: ConversationMessage, agentId?: string): Promise<void> {
|
|
125
|
+
this.db.prepare(
|
|
126
|
+
'INSERT INTO messages (customer_id, role, content, tool_calls, agent_id, timestamp) VALUES (?, ?, ?, ?, ?, ?)'
|
|
127
|
+
).run(
|
|
128
|
+
customerId,
|
|
129
|
+
message.role,
|
|
130
|
+
message.content,
|
|
131
|
+
message.toolCalls ? JSON.stringify(message.toolCalls) : null,
|
|
132
|
+
agentId || null,
|
|
133
|
+
message.timestamp,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async clearHistory(customerId?: string, agentId?: string): Promise<{ deletedCount: number }> {
|
|
138
|
+
let query = 'DELETE FROM messages';
|
|
139
|
+
const conditions: string[] = [];
|
|
140
|
+
const params: any[] = [];
|
|
141
|
+
|
|
142
|
+
if (customerId) {
|
|
143
|
+
conditions.push('customer_id = ?');
|
|
144
|
+
params.push(customerId);
|
|
145
|
+
}
|
|
146
|
+
if (agentId) {
|
|
147
|
+
conditions.push('agent_id = ?');
|
|
148
|
+
params.push(agentId);
|
|
149
|
+
}
|
|
150
|
+
if (conditions.length > 0) {
|
|
151
|
+
query += ' WHERE ' + conditions.join(' AND ');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result = this.db.prepare(query).run(...params);
|
|
155
|
+
return { deletedCount: result.changes };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async getSetting(key: string): Promise<string | null> {
|
|
159
|
+
const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
|
160
|
+
return row?.value ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async setSetting(key: string, value: string | null): Promise<void> {
|
|
164
|
+
if (value === null) {
|
|
165
|
+
this.db.prepare('DELETE FROM settings WHERE key = ?').run(key);
|
|
166
|
+
} else {
|
|
167
|
+
this.db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async close(): Promise<void> {
|
|
172
|
+
this.db.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private rowToCustomer(row: any): Customer {
|
|
176
|
+
return {
|
|
177
|
+
id: row.id,
|
|
178
|
+
phone: row.phone || undefined,
|
|
179
|
+
email: row.email || undefined,
|
|
180
|
+
name: row.name || undefined,
|
|
181
|
+
whatsappId: row.whatsapp_id || undefined,
|
|
182
|
+
instagramId: row.instagram_id || undefined,
|
|
183
|
+
facebookId: row.facebook_id || undefined,
|
|
184
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
185
|
+
lifetimeValue: row.lifetime_value || undefined,
|
|
186
|
+
firstInteraction: row.first_interaction ? new Date(row.first_interaction) : undefined,
|
|
187
|
+
lastInteraction: row.last_interaction ? new Date(row.last_interaction) : undefined,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/types.ts
ADDED
package/tsconfig.json
ADDED