@awareness-sdk/local 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/bin/awareness-local.mjs +489 -0
- package/package.json +31 -0
- package/src/api.mjs +122 -0
- package/src/core/cloud-sync.mjs +970 -0
- package/src/core/config.mjs +303 -0
- package/src/core/embedder.mjs +239 -0
- package/src/core/index.mjs +34 -0
- package/src/core/indexer.mjs +726 -0
- package/src/core/knowledge-extractor.mjs +629 -0
- package/src/core/memory-store.mjs +665 -0
- package/src/core/search.mjs +633 -0
- package/src/daemon.mjs +1720 -0
- package/src/mcp-server.mjs +335 -0
- package/src/spec/awareness-spec.json +393 -0
- package/src/web/index.html +1015 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudSync — Optional cloud synchronisation client for Awareness Local.
|
|
3
|
+
*
|
|
4
|
+
* Implements three-layer sync guarantee:
|
|
5
|
+
* Layer 1: SSE real-time push (second-level latency)
|
|
6
|
+
* Layer 2: Incremental pull on awareness_init / daemon start
|
|
7
|
+
* Layer 3: Periodic polling fallback (minute-level)
|
|
8
|
+
*
|
|
9
|
+
* Uses only Node.js built-in http/https modules — zero external dependencies.
|
|
10
|
+
*
|
|
11
|
+
* Cloud unavailability MUST NOT crash the daemon.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import https from 'node:https';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const LOG_PREFIX = '[CloudSync]';
|
|
23
|
+
const SSE_RECONNECT_BASE_MS = 5_000;
|
|
24
|
+
const SSE_RECONNECT_MAX_MS = 60_000;
|
|
25
|
+
const DEFAULT_PERIODIC_INTERVAL_MIN = 5;
|
|
26
|
+
const DEFAULT_POLL_INTERVAL_SEC = 5;
|
|
27
|
+
const DEFAULT_POLL_TIMEOUT_SEC = 300;
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers — minimal HTTP client using built-in http/https
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Perform an HTTP(S) request and return { status, headers, body }.
|
|
35
|
+
* Resolves even on non-2xx so callers can inspect status.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} url — Full URL
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} opts.method — HTTP method
|
|
40
|
+
* @param {object} opts.headers — Request headers
|
|
41
|
+
* @param {string} [opts.body] — JSON string body
|
|
42
|
+
* @param {number} [opts.timeout=15000]
|
|
43
|
+
* @returns {Promise<{ status: number, headers: object, body: string }>}
|
|
44
|
+
*/
|
|
45
|
+
function httpRequest(url, opts = {}) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const parsed = new URL(url);
|
|
48
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
49
|
+
const reqOpts = {
|
|
50
|
+
hostname: parsed.hostname,
|
|
51
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
52
|
+
path: parsed.pathname + parsed.search,
|
|
53
|
+
method: opts.method || 'GET',
|
|
54
|
+
headers: opts.headers || {},
|
|
55
|
+
timeout: opts.timeout ?? 15_000,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const req = transport.request(reqOpts, (res) => {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
res.on('data', (c) => chunks.push(c));
|
|
61
|
+
res.on('end', () => {
|
|
62
|
+
resolve({
|
|
63
|
+
status: res.statusCode,
|
|
64
|
+
headers: res.headers,
|
|
65
|
+
body: Buffer.concat(chunks).toString('utf-8'),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
req.on('timeout', () => {
|
|
72
|
+
req.destroy(new Error('Request timeout'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (opts.body) {
|
|
76
|
+
req.write(opts.body);
|
|
77
|
+
}
|
|
78
|
+
req.end();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Open an SSE (Server-Sent Events) connection.
|
|
84
|
+
* Returns an object with { req, res } on success; the caller reads `res`.
|
|
85
|
+
* The caller is responsible for destroying `req` to close the connection.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} url
|
|
88
|
+
* @param {object} headers
|
|
89
|
+
* @returns {Promise<{ req: http.ClientRequest, res: http.IncomingMessage }>}
|
|
90
|
+
*/
|
|
91
|
+
function openSSEStream(url, headers = {}) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const parsed = new URL(url);
|
|
94
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
95
|
+
const reqOpts = {
|
|
96
|
+
hostname: parsed.hostname,
|
|
97
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
98
|
+
path: parsed.pathname + parsed.search,
|
|
99
|
+
method: 'GET',
|
|
100
|
+
headers: {
|
|
101
|
+
Accept: 'text/event-stream',
|
|
102
|
+
'Cache-Control': 'no-cache',
|
|
103
|
+
Connection: 'keep-alive',
|
|
104
|
+
...headers,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const req = transport.request(reqOpts, (res) => {
|
|
109
|
+
if (res.statusCode !== 200) {
|
|
110
|
+
const chunks = [];
|
|
111
|
+
res.on('data', (c) => chunks.push(c));
|
|
112
|
+
res.on('end', () => {
|
|
113
|
+
reject(
|
|
114
|
+
new Error(
|
|
115
|
+
`SSE connection failed: HTTP ${res.statusCode} — ${Buffer.concat(chunks).toString('utf-8').slice(0, 200)}`
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
resolve({ req, res });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
req.on('error', reject);
|
|
125
|
+
req.end();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse an SSE buffer into discrete events.
|
|
131
|
+
* Returns { parsed: Array<{ event, data }>, remainder: string }.
|
|
132
|
+
*
|
|
133
|
+
* SSE format:
|
|
134
|
+
* event: <type>\n
|
|
135
|
+
* data: <payload>\n
|
|
136
|
+
* \n
|
|
137
|
+
*
|
|
138
|
+
* @param {string} buffer
|
|
139
|
+
* @returns {{ parsed: Array<{ event: string, data: string }>, remainder: string }}
|
|
140
|
+
*/
|
|
141
|
+
function parseSSE(buffer) {
|
|
142
|
+
const parsed = [];
|
|
143
|
+
// Split on double newline — each block is one event
|
|
144
|
+
const blocks = buffer.split('\n\n');
|
|
145
|
+
// The last element may be an incomplete block
|
|
146
|
+
const remainder = blocks.pop() || '';
|
|
147
|
+
|
|
148
|
+
for (const block of blocks) {
|
|
149
|
+
if (!block.trim()) continue;
|
|
150
|
+
|
|
151
|
+
let event = 'message';
|
|
152
|
+
let data = '';
|
|
153
|
+
|
|
154
|
+
for (const line of block.split('\n')) {
|
|
155
|
+
if (line.startsWith('event:')) {
|
|
156
|
+
event = line.slice(6).trim();
|
|
157
|
+
} else if (line.startsWith('data:')) {
|
|
158
|
+
data += line.slice(5).trim();
|
|
159
|
+
} else if (line.startsWith(':')) {
|
|
160
|
+
// Comment line — ignore (often used for keepalive)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (data) {
|
|
165
|
+
parsed.push({ event, data });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { parsed, remainder };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// CloudSync
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
export class CloudSync {
|
|
177
|
+
/**
|
|
178
|
+
* @param {object} config — Full config from .awareness/config.json
|
|
179
|
+
* @param {object} indexer — Indexer instance (SQLite access)
|
|
180
|
+
* @param {object} memoryStore — MemoryStore instance (markdown read/write)
|
|
181
|
+
*/
|
|
182
|
+
constructor(config, indexer, memoryStore) {
|
|
183
|
+
this.config = config;
|
|
184
|
+
this.apiBase = config.cloud?.api_base || 'https://awareness.market/api/v1';
|
|
185
|
+
this.apiKey = config.cloud?.api_key || '';
|
|
186
|
+
this.memoryId = config.cloud?.memory_id || '';
|
|
187
|
+
this.deviceId = config.device?.id || 'unknown-device';
|
|
188
|
+
this.indexer = indexer;
|
|
189
|
+
this.memoryStore = memoryStore;
|
|
190
|
+
|
|
191
|
+
// SSE connection state
|
|
192
|
+
this._sseReq = null; // http.ClientRequest — destroy to close
|
|
193
|
+
this._sseReconnectMs = SSE_RECONNECT_BASE_MS;
|
|
194
|
+
this._sseReconnectTimer = null;
|
|
195
|
+
this._sseStopped = false;
|
|
196
|
+
|
|
197
|
+
// Periodic sync interval handle
|
|
198
|
+
this._periodicTimer = null;
|
|
199
|
+
|
|
200
|
+
// Ensure sync_state table has the columns we need (ALTER is idempotent via IF NOT EXISTS in schema)
|
|
201
|
+
this._ensureSyncSchema();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// =========================================================================
|
|
205
|
+
// Public API
|
|
206
|
+
// =========================================================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether cloud sync is enabled and has the necessary credentials.
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
isEnabled() {
|
|
213
|
+
return !!(
|
|
214
|
+
this.config.cloud?.enabled &&
|
|
215
|
+
this.apiKey &&
|
|
216
|
+
this.memoryId
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Initiate device-auth flow.
|
|
222
|
+
* POST /auth/device/init
|
|
223
|
+
* @returns {Promise<{ device_code: string, user_code: string, verification_uri: string } | null>}
|
|
224
|
+
*/
|
|
225
|
+
async initAuth() {
|
|
226
|
+
try {
|
|
227
|
+
const result = await this._post('/auth/device/init', {
|
|
228
|
+
device_id: this.deviceId,
|
|
229
|
+
device_name: this.config.device?.name || 'Awareness Local',
|
|
230
|
+
});
|
|
231
|
+
return result;
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error(`${LOG_PREFIX} initAuth failed:`, err.message);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Poll for device-auth completion.
|
|
240
|
+
* POST /auth/device/poll
|
|
241
|
+
*
|
|
242
|
+
* @param {string} deviceCode — The device_code from initAuth()
|
|
243
|
+
* @param {number} [interval=5] — Poll interval in seconds
|
|
244
|
+
* @param {number} [timeout=300] — Total timeout in seconds
|
|
245
|
+
* @returns {Promise<{ api_key: string, memory_id?: string } | null>}
|
|
246
|
+
*/
|
|
247
|
+
async pollAuth(deviceCode, interval = DEFAULT_POLL_INTERVAL_SEC, timeout = DEFAULT_POLL_TIMEOUT_SEC) {
|
|
248
|
+
const deadline = Date.now() + timeout * 1000;
|
|
249
|
+
|
|
250
|
+
while (Date.now() < deadline) {
|
|
251
|
+
try {
|
|
252
|
+
const result = await this._post('/auth/device/poll', {
|
|
253
|
+
device_code: deviceCode,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (result && result.api_key) {
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Not yet approved — wait and retry
|
|
261
|
+
if (result && result.status === 'pending') {
|
|
262
|
+
await this._sleep(interval * 1000);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Denied or error
|
|
267
|
+
if (result && (result.status === 'denied' || result.error)) {
|
|
268
|
+
console.error(`${LOG_PREFIX} pollAuth denied:`, result.error || 'User denied');
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
} catch (err) {
|
|
272
|
+
console.warn(`${LOG_PREFIX} pollAuth error (retrying):`, err.message);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await this._sleep(interval * 1000);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.warn(`${LOG_PREFIX} pollAuth timed out after ${timeout}s`);
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Push unsynced local memories to the cloud.
|
|
284
|
+
* Finds all memories with synced_to_cloud=0 and POSTs them to /mcp/events.
|
|
285
|
+
*
|
|
286
|
+
* @returns {Promise<{ synced: number, errors: number }>}
|
|
287
|
+
*/
|
|
288
|
+
async syncToCloud() {
|
|
289
|
+
if (!this.isEnabled()) return { synced: 0, errors: 0 };
|
|
290
|
+
|
|
291
|
+
let synced = 0;
|
|
292
|
+
let errors = 0;
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const unsynced = this.indexer.db
|
|
296
|
+
.prepare('SELECT * FROM memories WHERE synced_to_cloud = 0 ORDER BY created_at')
|
|
297
|
+
.all();
|
|
298
|
+
|
|
299
|
+
if (!unsynced.length) return { synced: 0, errors: 0 };
|
|
300
|
+
|
|
301
|
+
for (const memory of unsynced) {
|
|
302
|
+
try {
|
|
303
|
+
// Read the full markdown content from disk
|
|
304
|
+
let content = '';
|
|
305
|
+
try {
|
|
306
|
+
content = fs.readFileSync(
|
|
307
|
+
memory.filepath,
|
|
308
|
+
'utf-8'
|
|
309
|
+
);
|
|
310
|
+
} catch {
|
|
311
|
+
// File may have been deleted — skip
|
|
312
|
+
console.warn(`${LOG_PREFIX} File not found, skipping: ${memory.filepath}`);
|
|
313
|
+
errors++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Gather local vector if available
|
|
318
|
+
const embedding = this.indexer.db
|
|
319
|
+
.prepare('SELECT vector, model_id FROM embeddings WHERE memory_id = ?')
|
|
320
|
+
.get(memory.id);
|
|
321
|
+
|
|
322
|
+
const metadata = {
|
|
323
|
+
local_id: memory.id,
|
|
324
|
+
device_id: this.deviceId,
|
|
325
|
+
agent_role: memory.agent_role,
|
|
326
|
+
tags: this._parseTags(memory.tags),
|
|
327
|
+
source: memory.source || 'awareness-local',
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Attach local vector for cloud to optionally reuse
|
|
331
|
+
if (embedding) {
|
|
332
|
+
try {
|
|
333
|
+
const floats = new Float32Array(embedding.vector.buffer, embedding.vector.byteOffset, embedding.vector.byteLength / 4);
|
|
334
|
+
metadata.local_vector = Array.from(floats);
|
|
335
|
+
metadata.local_model = embedding.model_id;
|
|
336
|
+
metadata.local_dim = floats.length;
|
|
337
|
+
} catch {
|
|
338
|
+
// Vector decode failed — send without
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const result = await this._post('/mcp/events', {
|
|
343
|
+
memory_id: this.memoryId,
|
|
344
|
+
events: [
|
|
345
|
+
{
|
|
346
|
+
event_type: memory.type,
|
|
347
|
+
content,
|
|
348
|
+
metadata,
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Mark as synced
|
|
354
|
+
const cloudId = result?.cloud_id || result?.ids?.[0] || null;
|
|
355
|
+
this.indexer.db
|
|
356
|
+
.prepare(
|
|
357
|
+
'UPDATE memories SET synced_to_cloud = 1 WHERE id = ?'
|
|
358
|
+
)
|
|
359
|
+
.run(memory.id);
|
|
360
|
+
|
|
361
|
+
// Store cloud_id mapping in sync_state for reference
|
|
362
|
+
if (cloudId) {
|
|
363
|
+
this._setSyncState(`cloud_id:${memory.id}`, cloudId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
synced++;
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.warn(`${LOG_PREFIX} Failed to push memory ${memory.id}:`, err.message);
|
|
369
|
+
errors++;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (synced > 0) {
|
|
374
|
+
console.log(`${LOG_PREFIX} Pushed ${synced} memories to cloud` + (errors ? ` (${errors} errors)` : ''));
|
|
375
|
+
}
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error(`${LOG_PREFIX} syncToCloud failed:`, err.message);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { synced, errors };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Pull new memories from the cloud using cursor-based pagination.
|
|
385
|
+
* Excludes items written by this device to prevent loops.
|
|
386
|
+
*
|
|
387
|
+
* @returns {Promise<{ pulled: number }>}
|
|
388
|
+
*/
|
|
389
|
+
async pullFromCloud() {
|
|
390
|
+
if (!this.isEnabled()) return { pulled: 0 };
|
|
391
|
+
|
|
392
|
+
let pulled = 0;
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const cursor = this._getSyncState('pull_cursor') || '';
|
|
396
|
+
|
|
397
|
+
const qs = new URLSearchParams({ limit: '50' });
|
|
398
|
+
if (cursor) qs.set('cursor', cursor);
|
|
399
|
+
|
|
400
|
+
const result = await this._get(
|
|
401
|
+
`/memories/${this.memoryId}/content?${qs.toString()}`
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// API may return array directly or { items: [...] }
|
|
405
|
+
const items = Array.isArray(result) ? result : (result?.items || []);
|
|
406
|
+
if (!items.length) return { pulled: 0 };
|
|
407
|
+
|
|
408
|
+
for (const item of items) {
|
|
409
|
+
// Skip items we pushed ourselves (by checking device_id in metadata)
|
|
410
|
+
const itemDeviceId = item.metadata?.device_id || item.device_id;
|
|
411
|
+
if (itemDeviceId === this.deviceId) continue;
|
|
412
|
+
// Check if we already have this cloud item (by cloud_id mapping)
|
|
413
|
+
const existing = this._getSyncState(`cloud_id_reverse:${item.id}`);
|
|
414
|
+
if (existing) continue;
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
await this._pullSingleItem(item);
|
|
418
|
+
pulled++;
|
|
419
|
+
} catch (err) {
|
|
420
|
+
console.warn(`${LOG_PREFIX} Failed to pull item ${item.id}:`, err.message);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Advance cursor
|
|
425
|
+
if (result.next_cursor) {
|
|
426
|
+
this._setSyncState('pull_cursor', result.next_cursor);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (pulled > 0) {
|
|
430
|
+
console.log(`${LOG_PREFIX} Pulled ${pulled} memories from cloud`);
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error(`${LOG_PREFIX} pullFromCloud failed:`, err.message);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { pulled };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Full bidirectional sync: push then pull.
|
|
441
|
+
* @returns {Promise<{ pushed: number, pulled: number }>}
|
|
442
|
+
*/
|
|
443
|
+
async fullSync() {
|
|
444
|
+
const pushResult = await this.syncToCloud();
|
|
445
|
+
const pullResult = await this.pullFromCloud();
|
|
446
|
+
return {
|
|
447
|
+
pushed: pushResult.synced,
|
|
448
|
+
pulled: pullResult.pulled,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Connect to the cloud SSE endpoint for real-time event streaming.
|
|
454
|
+
* Automatically reconnects on disconnect with exponential backoff.
|
|
455
|
+
*/
|
|
456
|
+
async startSSE() {
|
|
457
|
+
if (!this.isEnabled()) return;
|
|
458
|
+
this._sseStopped = false;
|
|
459
|
+
|
|
460
|
+
const url = `${this.apiBase}/memories/${this.memoryId}/events/stream`;
|
|
461
|
+
const headers = {
|
|
462
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
463
|
+
'X-Awareness-Device-Id': this.deviceId,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const { req, res } = await openSSEStream(url, headers);
|
|
468
|
+
this._sseReq = req;
|
|
469
|
+
this._sseReconnectMs = SSE_RECONNECT_BASE_MS; // Reset backoff on success
|
|
470
|
+
|
|
471
|
+
console.log(`${LOG_PREFIX} SSE connected to cloud`);
|
|
472
|
+
|
|
473
|
+
let buffer = '';
|
|
474
|
+
res.setEncoding('utf-8');
|
|
475
|
+
|
|
476
|
+
res.on('data', (chunk) => {
|
|
477
|
+
buffer += chunk;
|
|
478
|
+
const { parsed, remainder } = parseSSE(buffer);
|
|
479
|
+
buffer = remainder;
|
|
480
|
+
|
|
481
|
+
for (const event of parsed) {
|
|
482
|
+
// Handle event asynchronously — don't block the stream
|
|
483
|
+
this._handleSSEEvent(event).catch((err) => {
|
|
484
|
+
console.warn(`${LOG_PREFIX} SSE event handler error:`, err.message);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
res.on('end', () => {
|
|
490
|
+
console.warn(`${LOG_PREFIX} SSE stream ended`);
|
|
491
|
+
this._scheduleSSEReconnect();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
res.on('error', (err) => {
|
|
495
|
+
console.warn(`${LOG_PREFIX} SSE stream error:`, err.message);
|
|
496
|
+
this._scheduleSSEReconnect();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
} catch (err) {
|
|
500
|
+
console.warn(`${LOG_PREFIX} SSE connection failed:`, err.message);
|
|
501
|
+
this._scheduleSSEReconnect();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Start periodic sync fallback (Layer 3).
|
|
507
|
+
* @param {number} [intervalMin=5] — Interval in minutes
|
|
508
|
+
*/
|
|
509
|
+
startPeriodicSync(intervalMin = DEFAULT_PERIODIC_INTERVAL_MIN) {
|
|
510
|
+
if (intervalMin <= 0) return;
|
|
511
|
+
|
|
512
|
+
this._periodicTimer = setInterval(async () => {
|
|
513
|
+
try {
|
|
514
|
+
const result = await this.fullSync();
|
|
515
|
+
if (result.pushed > 0 || result.pulled > 0) {
|
|
516
|
+
console.log(
|
|
517
|
+
`${LOG_PREFIX} Periodic sync: pushed ${result.pushed}, pulled ${result.pulled}`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.warn(`${LOG_PREFIX} Periodic sync failed:`, err.message);
|
|
522
|
+
}
|
|
523
|
+
}, intervalMin * 60 * 1000);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Start all sync layers:
|
|
528
|
+
* 1. Pull from cloud (catch up while offline)
|
|
529
|
+
* 2. Push unsynced local memories
|
|
530
|
+
* 3. Start SSE real-time stream
|
|
531
|
+
* 4. Start periodic fallback
|
|
532
|
+
*/
|
|
533
|
+
async start() {
|
|
534
|
+
if (!this.isEnabled()) {
|
|
535
|
+
console.log(`${LOG_PREFIX} Cloud sync disabled (missing credentials or not enabled)`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
console.log(`${LOG_PREFIX} Starting cloud sync...`);
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
// Layer 2: Catch-up pull
|
|
543
|
+
await this.pullFromCloud();
|
|
544
|
+
} catch (err) {
|
|
545
|
+
console.warn(`${LOG_PREFIX} Initial pull failed (will retry):`, err.message);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
// Push unsynced
|
|
550
|
+
await this.syncToCloud();
|
|
551
|
+
} catch (err) {
|
|
552
|
+
console.warn(`${LOG_PREFIX} Initial push failed (will retry):`, err.message);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Layer 1: SSE real-time (non-blocking)
|
|
556
|
+
this.startSSE();
|
|
557
|
+
|
|
558
|
+
// Layer 3: Periodic fallback
|
|
559
|
+
const intervalMin = this.config.cloud?.sync_interval_min || DEFAULT_PERIODIC_INTERVAL_MIN;
|
|
560
|
+
this.startPeriodicSync(intervalMin);
|
|
561
|
+
|
|
562
|
+
console.log(`${LOG_PREFIX} Started: SSE + periodic sync (${intervalMin}min) enabled`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Stop all sync activity gracefully.
|
|
567
|
+
*/
|
|
568
|
+
stop() {
|
|
569
|
+
this._sseStopped = true;
|
|
570
|
+
|
|
571
|
+
// Abort SSE connection
|
|
572
|
+
if (this._sseReq) {
|
|
573
|
+
try {
|
|
574
|
+
this._sseReq.destroy();
|
|
575
|
+
} catch {
|
|
576
|
+
// ignore
|
|
577
|
+
}
|
|
578
|
+
this._sseReq = null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Clear SSE reconnect timer
|
|
582
|
+
if (this._sseReconnectTimer) {
|
|
583
|
+
clearTimeout(this._sseReconnectTimer);
|
|
584
|
+
this._sseReconnectTimer = null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Clear periodic sync
|
|
588
|
+
if (this._periodicTimer) {
|
|
589
|
+
clearInterval(this._periodicTimer);
|
|
590
|
+
this._periodicTimer = null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
console.log(`${LOG_PREFIX} Stopped`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// =========================================================================
|
|
597
|
+
// Internal — SSE event handling
|
|
598
|
+
// =========================================================================
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Handle a single parsed SSE event.
|
|
602
|
+
* @param {{ event: string, data: string }} sseEvent
|
|
603
|
+
*/
|
|
604
|
+
async _handleSSEEvent(sseEvent) {
|
|
605
|
+
let data;
|
|
606
|
+
try {
|
|
607
|
+
data = JSON.parse(sseEvent.data);
|
|
608
|
+
} catch {
|
|
609
|
+
return; // Malformed data — skip silently
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
switch (sseEvent.event) {
|
|
613
|
+
case 'memory_created':
|
|
614
|
+
case 'memory_updated': {
|
|
615
|
+
// Skip our own events (anti-loop)
|
|
616
|
+
if (data.device_id === this.deviceId) return;
|
|
617
|
+
|
|
618
|
+
// Check if already pulled
|
|
619
|
+
const existing = this._getSyncState(`cloud_id_reverse:${data.id}`);
|
|
620
|
+
if (existing) return;
|
|
621
|
+
|
|
622
|
+
// Pull the full content
|
|
623
|
+
try {
|
|
624
|
+
await this._pullSingleItem(data);
|
|
625
|
+
console.log(
|
|
626
|
+
`${LOG_PREFIX} SSE received: ${data.title || data.id} (from ${data.source || 'cloud'})`
|
|
627
|
+
);
|
|
628
|
+
} catch (err) {
|
|
629
|
+
console.warn(`${LOG_PREFIX} SSE pull failed for ${data.id}:`, err.message);
|
|
630
|
+
}
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
case 'knowledge_extracted': {
|
|
635
|
+
if (data.device_id === this.deviceId) return;
|
|
636
|
+
try {
|
|
637
|
+
await this._pullKnowledgeCard(data);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
console.warn(`${LOG_PREFIX} SSE knowledge pull failed:`, err.message);
|
|
640
|
+
}
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
case 'heartbeat':
|
|
645
|
+
// Keepalive — no action needed
|
|
646
|
+
break;
|
|
647
|
+
|
|
648
|
+
default:
|
|
649
|
+
// Unknown event type — ignore gracefully
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Schedule an SSE reconnection with exponential backoff.
|
|
656
|
+
*/
|
|
657
|
+
_scheduleSSEReconnect() {
|
|
658
|
+
if (this._sseStopped) return;
|
|
659
|
+
|
|
660
|
+
// Clean up current request
|
|
661
|
+
if (this._sseReq) {
|
|
662
|
+
try {
|
|
663
|
+
this._sseReq.destroy();
|
|
664
|
+
} catch {
|
|
665
|
+
// ignore
|
|
666
|
+
}
|
|
667
|
+
this._sseReq = null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const delay = this._sseReconnectMs;
|
|
671
|
+
this._sseReconnectMs = Math.min(
|
|
672
|
+
this._sseReconnectMs * 2,
|
|
673
|
+
SSE_RECONNECT_MAX_MS
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
this._sseRetryCount = (this._sseRetryCount || 0) + 1;
|
|
677
|
+
if (this._sseRetryCount >= 3) {
|
|
678
|
+
console.log(`${LOG_PREFIX} SSE unavailable after 3 retries — falling back to periodic sync only`);
|
|
679
|
+
return; // Stop retrying; periodic sync will compensate
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
console.log(`${LOG_PREFIX} SSE reconnecting in ${Math.round(delay / 1000)}s... (retry ${this._sseRetryCount}/3)`);
|
|
683
|
+
|
|
684
|
+
this._sseReconnectTimer = setTimeout(() => {
|
|
685
|
+
this._sseReconnectTimer = null;
|
|
686
|
+
this.startSSE();
|
|
687
|
+
}, delay);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// =========================================================================
|
|
691
|
+
// Internal — pull helpers
|
|
692
|
+
// =========================================================================
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Pull a single memory item from cloud and write to local store.
|
|
696
|
+
* @param {object} item — { id, event_type, content, metadata, ... }
|
|
697
|
+
*/
|
|
698
|
+
async _pullSingleItem(item) {
|
|
699
|
+
// If item doesn't have content, fetch it from the API
|
|
700
|
+
let content = item.content;
|
|
701
|
+
let eventType = item.event_type || item.type || 'turn_summary';
|
|
702
|
+
let tags = item.metadata?.tags || item.tags || [];
|
|
703
|
+
let agentRole = item.metadata?.agent_role || item.agent_role;
|
|
704
|
+
let source = item.metadata?.source || item.source || 'cloud-pull';
|
|
705
|
+
|
|
706
|
+
if (!content && item.id) {
|
|
707
|
+
const detail = await this._get(
|
|
708
|
+
`/memories/${this.memoryId}/content/${item.id}`
|
|
709
|
+
);
|
|
710
|
+
if (!detail) return;
|
|
711
|
+
content = detail.content || '';
|
|
712
|
+
eventType = detail.event_type || eventType;
|
|
713
|
+
tags = detail.metadata?.tags || tags;
|
|
714
|
+
agentRole = detail.metadata?.agent_role || agentRole;
|
|
715
|
+
source = detail.metadata?.source || source;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (!content) return;
|
|
719
|
+
|
|
720
|
+
// Write to local markdown
|
|
721
|
+
const { id, filepath } = await this.memoryStore.write({
|
|
722
|
+
type: eventType,
|
|
723
|
+
content,
|
|
724
|
+
title: item.title || '',
|
|
725
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
726
|
+
agent_role: agentRole,
|
|
727
|
+
session_id: item.session_id || '',
|
|
728
|
+
source,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Index in SQLite
|
|
732
|
+
this.indexer.indexMemory(
|
|
733
|
+
id,
|
|
734
|
+
{
|
|
735
|
+
type: eventType,
|
|
736
|
+
title: item.title || '',
|
|
737
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
738
|
+
agent_role: agentRole,
|
|
739
|
+
session_id: item.session_id || '',
|
|
740
|
+
source,
|
|
741
|
+
filepath,
|
|
742
|
+
synced_to_cloud: true,
|
|
743
|
+
},
|
|
744
|
+
content
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// Mark synced and store cloud_id mapping (bidirectional)
|
|
748
|
+
this.indexer.db
|
|
749
|
+
.prepare('UPDATE memories SET synced_to_cloud = 1 WHERE id = ?')
|
|
750
|
+
.run(id);
|
|
751
|
+
|
|
752
|
+
if (item.id) {
|
|
753
|
+
this._setSyncState(`cloud_id:${id}`, item.id);
|
|
754
|
+
this._setSyncState(`cloud_id_reverse:${item.id}`, id);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Pull a knowledge card from cloud SSE event data and store locally.
|
|
760
|
+
* @param {object} data — { id, category, title, summary, tags, ... }
|
|
761
|
+
*/
|
|
762
|
+
async _pullKnowledgeCard(data) {
|
|
763
|
+
if (!data.title || !data.category) return;
|
|
764
|
+
|
|
765
|
+
// Check if we already have this knowledge card
|
|
766
|
+
const existing = this._getSyncState(`cloud_kc:${data.id}`);
|
|
767
|
+
if (existing) return;
|
|
768
|
+
|
|
769
|
+
// Write knowledge card markdown
|
|
770
|
+
const kcContent = [
|
|
771
|
+
`# ${data.title}`,
|
|
772
|
+
'',
|
|
773
|
+
data.summary ? `**Summary**: ${data.summary}` : '',
|
|
774
|
+
data.key_insight ? `**Key Insight**: ${data.key_insight}` : '',
|
|
775
|
+
]
|
|
776
|
+
.filter(Boolean)
|
|
777
|
+
.join('\n');
|
|
778
|
+
|
|
779
|
+
// Store via memoryStore's knowledge write (if available) or direct file write
|
|
780
|
+
const kcId = `kc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
781
|
+
const tags = Array.isArray(data.tags) ? data.tags : [];
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
this.indexer.db
|
|
785
|
+
.prepare(
|
|
786
|
+
`INSERT OR IGNORE INTO knowledge_cards
|
|
787
|
+
(id, category, title, summary, source_memories, confidence, status, tags, created_at, filepath)
|
|
788
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
789
|
+
)
|
|
790
|
+
.run(
|
|
791
|
+
kcId,
|
|
792
|
+
data.category,
|
|
793
|
+
data.title,
|
|
794
|
+
data.summary || '',
|
|
795
|
+
JSON.stringify(data.source_memories || []),
|
|
796
|
+
data.confidence || 0.8,
|
|
797
|
+
'active',
|
|
798
|
+
JSON.stringify(tags),
|
|
799
|
+
new Date().toISOString(),
|
|
800
|
+
`cloud-pull:${data.id}`
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
this._setSyncState(`cloud_kc:${data.id}`, kcId);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
// Duplicate or constraint error — safe to ignore
|
|
806
|
+
if (!err.message?.includes('UNIQUE')) {
|
|
807
|
+
throw err;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// =========================================================================
|
|
813
|
+
// Internal — HTTP helpers
|
|
814
|
+
// =========================================================================
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* GET request to cloud API.
|
|
818
|
+
* @param {string} endpoint — Path relative to apiBase (e.g. "/memories/xxx/content")
|
|
819
|
+
* @returns {Promise<object|null>}
|
|
820
|
+
*/
|
|
821
|
+
async _get(endpoint) {
|
|
822
|
+
const url = `${this.apiBase}${endpoint}`;
|
|
823
|
+
try {
|
|
824
|
+
const { status, body } = await httpRequest(url, {
|
|
825
|
+
method: 'GET',
|
|
826
|
+
headers: this._authHeaders(),
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
if (status >= 200 && status < 300) {
|
|
830
|
+
return JSON.parse(body);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
console.warn(`${LOG_PREFIX} GET ${endpoint} → HTTP ${status}`);
|
|
834
|
+
return null;
|
|
835
|
+
} catch (err) {
|
|
836
|
+
console.warn(`${LOG_PREFIX} GET ${endpoint} failed:`, err.message);
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* POST request to cloud API.
|
|
843
|
+
* @param {string} endpoint
|
|
844
|
+
* @param {object} data
|
|
845
|
+
* @returns {Promise<object|null>}
|
|
846
|
+
*/
|
|
847
|
+
async _post(endpoint, data) {
|
|
848
|
+
const url = `${this.apiBase}${endpoint}`;
|
|
849
|
+
const jsonBody = JSON.stringify(data);
|
|
850
|
+
try {
|
|
851
|
+
const { status, body } = await httpRequest(url, {
|
|
852
|
+
method: 'POST',
|
|
853
|
+
headers: {
|
|
854
|
+
...this._authHeaders(),
|
|
855
|
+
'Content-Type': 'application/json',
|
|
856
|
+
'Content-Length': String(Buffer.byteLength(jsonBody)),
|
|
857
|
+
},
|
|
858
|
+
body: jsonBody,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
if (status >= 200 && status < 300) {
|
|
862
|
+
return JSON.parse(body);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
console.warn(`${LOG_PREFIX} POST ${endpoint} → HTTP ${status}: ${body.slice(0, 200)}`);
|
|
866
|
+
return null;
|
|
867
|
+
} catch (err) {
|
|
868
|
+
console.warn(`${LOG_PREFIX} POST ${endpoint} failed:`, err.message);
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Build authorization headers for cloud requests.
|
|
875
|
+
* @returns {object}
|
|
876
|
+
*/
|
|
877
|
+
_authHeaders() {
|
|
878
|
+
return {
|
|
879
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
880
|
+
'X-Awareness-Device-Id': this.deviceId,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// =========================================================================
|
|
885
|
+
// Internal — sync state persistence (via SQLite sync_state table)
|
|
886
|
+
// =========================================================================
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Ensure the sync_state table and any missing columns exist.
|
|
890
|
+
*/
|
|
891
|
+
_ensureSyncSchema() {
|
|
892
|
+
try {
|
|
893
|
+
// sync_state table is created by indexer schema init; just verify it exists
|
|
894
|
+
this.indexer.db
|
|
895
|
+
.prepare(
|
|
896
|
+
`CREATE TABLE IF NOT EXISTS sync_state (
|
|
897
|
+
key TEXT PRIMARY KEY,
|
|
898
|
+
value TEXT NOT NULL,
|
|
899
|
+
updated_at TEXT NOT NULL
|
|
900
|
+
)`
|
|
901
|
+
)
|
|
902
|
+
.run();
|
|
903
|
+
} catch {
|
|
904
|
+
// Table likely already exists
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Get a value from sync_state.
|
|
910
|
+
* @param {string} key
|
|
911
|
+
* @returns {string|null}
|
|
912
|
+
*/
|
|
913
|
+
_getSyncState(key) {
|
|
914
|
+
try {
|
|
915
|
+
const row = this.indexer.db
|
|
916
|
+
.prepare('SELECT value FROM sync_state WHERE key = ?')
|
|
917
|
+
.get(key);
|
|
918
|
+
return row?.value || null;
|
|
919
|
+
} catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Set a value in sync_state (upsert).
|
|
926
|
+
* @param {string} key
|
|
927
|
+
* @param {string} value
|
|
928
|
+
*/
|
|
929
|
+
_setSyncState(key, value) {
|
|
930
|
+
try {
|
|
931
|
+
this.indexer.db
|
|
932
|
+
.prepare(
|
|
933
|
+
`INSERT INTO sync_state (key, value, updated_at)
|
|
934
|
+
VALUES (?, ?, ?)
|
|
935
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
936
|
+
)
|
|
937
|
+
.run(key, value, new Date().toISOString());
|
|
938
|
+
} catch {
|
|
939
|
+
// Non-critical — log and continue
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// =========================================================================
|
|
944
|
+
// Internal — utilities
|
|
945
|
+
// =========================================================================
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Parse tags JSON string safely.
|
|
949
|
+
* @param {string} tagsStr — JSON array string or empty
|
|
950
|
+
* @returns {string[]}
|
|
951
|
+
*/
|
|
952
|
+
_parseTags(tagsStr) {
|
|
953
|
+
if (!tagsStr) return [];
|
|
954
|
+
try {
|
|
955
|
+
const parsed = JSON.parse(tagsStr);
|
|
956
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
957
|
+
} catch {
|
|
958
|
+
return [];
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Simple async sleep.
|
|
964
|
+
* @param {number} ms
|
|
965
|
+
* @returns {Promise<void>}
|
|
966
|
+
*/
|
|
967
|
+
_sleep(ms) {
|
|
968
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
969
|
+
}
|
|
970
|
+
}
|