@hasna/conversations 0.2.24 → 0.2.26
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/hook.js +7 -0
- package/bin/index.js +1567 -62
- package/bin/mcp.js +1582 -37
- package/dashboard/dist/assets/index-Bw0wMcXE.js +186 -0
- package/dashboard/dist/assets/index-CF_GDtNp.css +1 -0
- package/dashboard/dist/index.html +13 -0
- package/dashboard/dist/logo.jpg +0 -0
- package/dist/index.js +30 -0
- package/dist/lib/messages.d.ts +2 -0
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/tools/cloud.d.ts +7 -0
- package/dist/mcp/tools/cloud.test.d.ts +1 -0
- package/package.json +2 -2
package/bin/mcp.js
CHANGED
|
@@ -6537,6 +6537,75 @@ var require_dist = __commonJS((exports, module) => {
|
|
|
6537
6537
|
});
|
|
6538
6538
|
|
|
6539
6539
|
// node_modules/@hasna/cloud/dist/index.js
|
|
6540
|
+
var exports_dist = {};
|
|
6541
|
+
__export(exports_dist, {
|
|
6542
|
+
translateSql: () => translateSql,
|
|
6543
|
+
translateParams: () => translateParams,
|
|
6544
|
+
translateDdl: () => translateDdl,
|
|
6545
|
+
syncPush: () => syncPush,
|
|
6546
|
+
syncPull: () => syncPull,
|
|
6547
|
+
storeConflicts: () => storeConflicts,
|
|
6548
|
+
setupAutoSync: () => setupAutoSync,
|
|
6549
|
+
sendFeedback: () => sendFeedback,
|
|
6550
|
+
saveFeedback: () => saveFeedback,
|
|
6551
|
+
saveCloudConfig: () => saveCloudConfig,
|
|
6552
|
+
runScheduledSync: () => runScheduledSync,
|
|
6553
|
+
resolveConflicts: () => resolveConflicts,
|
|
6554
|
+
resolveConflict: () => resolveConflict,
|
|
6555
|
+
resetSyncMeta: () => resetSyncMeta,
|
|
6556
|
+
resetAllSyncMeta: () => resetAllSyncMeta,
|
|
6557
|
+
removeSyncSchedule: () => removeSyncSchedule,
|
|
6558
|
+
registerSyncSchedule: () => registerSyncSchedule,
|
|
6559
|
+
registerCloudTools: () => registerCloudTools,
|
|
6560
|
+
registerCloudCommands: () => registerCloudCommands,
|
|
6561
|
+
purgeResolvedConflicts: () => purgeResolvedConflicts,
|
|
6562
|
+
parseInterval: () => parseInterval,
|
|
6563
|
+
minutesToCron: () => minutesToCron,
|
|
6564
|
+
migrateService: () => migrateService,
|
|
6565
|
+
migrateDotfile: () => migrateDotfile,
|
|
6566
|
+
migrateAllServices: () => migrateAllServices,
|
|
6567
|
+
listSqliteTables: () => listSqliteTables,
|
|
6568
|
+
listPgTables: () => listPgTables,
|
|
6569
|
+
listFeedback: () => listFeedback,
|
|
6570
|
+
listConflicts: () => listConflicts,
|
|
6571
|
+
isSyncExcludedTable: () => isSyncExcludedTable,
|
|
6572
|
+
incrementalSyncPush: () => incrementalSyncPush,
|
|
6573
|
+
incrementalSyncPull: () => incrementalSyncPull,
|
|
6574
|
+
hasLegacyDotfile: () => hasLegacyDotfile,
|
|
6575
|
+
getWinningData: () => getWinningData,
|
|
6576
|
+
getSyncScheduleStatus: () => getSyncScheduleStatus,
|
|
6577
|
+
getSyncMetaForTable: () => getSyncMetaForTable,
|
|
6578
|
+
getSyncMetaAll: () => getSyncMetaAll,
|
|
6579
|
+
getServiceDbPath: () => getServiceDbPath,
|
|
6580
|
+
getHasnaDir: () => getHasnaDir,
|
|
6581
|
+
getDbPath: () => getDbPath,
|
|
6582
|
+
getDataDir: () => getDataDir,
|
|
6583
|
+
getConnectionString: () => getConnectionString,
|
|
6584
|
+
getConflict: () => getConflict,
|
|
6585
|
+
getConfigPath: () => getConfigPath,
|
|
6586
|
+
getConfigDir: () => getConfigDir,
|
|
6587
|
+
getCloudConfig: () => getCloudConfig,
|
|
6588
|
+
getAutoSyncConfig: () => getAutoSyncConfig,
|
|
6589
|
+
ensureSyncMetaTable: () => ensureSyncMetaTable,
|
|
6590
|
+
ensurePgDatabase: () => ensurePgDatabase,
|
|
6591
|
+
ensureFeedbackTable: () => ensureFeedbackTable,
|
|
6592
|
+
ensureConflictsTable: () => ensureConflictsTable,
|
|
6593
|
+
ensureAllPgDatabases: () => ensureAllPgDatabases,
|
|
6594
|
+
enableAutoSync: () => enableAutoSync,
|
|
6595
|
+
discoverSyncableServicesV2: () => discoverSyncableServices,
|
|
6596
|
+
discoverSyncableServices: () => discoverSyncableServices2,
|
|
6597
|
+
discoverServices: () => discoverServices,
|
|
6598
|
+
detectConflicts: () => detectConflicts,
|
|
6599
|
+
createDatabase: () => createDatabase,
|
|
6600
|
+
applyPgMigrations: () => applyPgMigrations,
|
|
6601
|
+
SyncProgressTracker: () => SyncProgressTracker,
|
|
6602
|
+
SqliteAdapter: () => SqliteAdapter,
|
|
6603
|
+
SYNC_EXCLUDED_TABLE_PATTERNS: () => SYNC_EXCLUDED_TABLE_PATTERNS,
|
|
6604
|
+
PgAdapterAsync: () => PgAdapterAsync,
|
|
6605
|
+
PgAdapter: () => PgAdapter,
|
|
6606
|
+
KNOWN_PG_SERVICES: () => KNOWN_PG_SERVICES,
|
|
6607
|
+
CloudConfigSchema: () => CloudConfigSchema
|
|
6608
|
+
});
|
|
6540
6609
|
import { createRequire } from "module";
|
|
6541
6610
|
import { Database } from "bun:sqlite";
|
|
6542
6611
|
import {
|
|
@@ -6554,9 +6623,13 @@ import { readdirSync as readdirSync2, existsSync as existsSync3 } from "fs";
|
|
|
6554
6623
|
import { join as join3 } from "path";
|
|
6555
6624
|
import { homedir as homedir3 } from "os";
|
|
6556
6625
|
import { hostname as hostname3 } from "os";
|
|
6626
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
6557
6627
|
import { homedir as homedir4 } from "os";
|
|
6558
6628
|
import { join as join4 } from "path";
|
|
6629
|
+
import { existsSync as existsSync5, readdirSync as readdirSync3 } from "fs";
|
|
6630
|
+
import { join as join5 } from "path";
|
|
6559
6631
|
import { join as join6, dirname } from "path";
|
|
6632
|
+
import { existsSync as existsSync6, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync3 } from "fs";
|
|
6560
6633
|
import { homedir as homedir5, platform } from "os";
|
|
6561
6634
|
function __accessProp2(key) {
|
|
6562
6635
|
return this[key];
|
|
@@ -6611,6 +6684,17 @@ function sqliteToPostgres(sql) {
|
|
|
6611
6684
|
}
|
|
6612
6685
|
return out;
|
|
6613
6686
|
}
|
|
6687
|
+
function translateDdl(ddl, dialect) {
|
|
6688
|
+
if (dialect === "sqlite")
|
|
6689
|
+
return ddl;
|
|
6690
|
+
let out = ddl;
|
|
6691
|
+
out = out.replace(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/gi, "BIGSERIAL PRIMARY KEY");
|
|
6692
|
+
out = out.replace(/\bAUTOINCREMENT\b/gi, "");
|
|
6693
|
+
out = out.replace(/\bREAL\b/gi, "DOUBLE PRECISION");
|
|
6694
|
+
out = out.replace(/\bBLOB\b/gi, "BYTEA");
|
|
6695
|
+
out = sqliteToPostgres(out);
|
|
6696
|
+
return out;
|
|
6697
|
+
}
|
|
6614
6698
|
|
|
6615
6699
|
class SqliteAdapter {
|
|
6616
6700
|
db;
|
|
@@ -7436,6 +7520,39 @@ function getDbPath(serviceName) {
|
|
|
7436
7520
|
const dir = getDataDir(serviceName);
|
|
7437
7521
|
return join(dir, `${serviceName}.db`);
|
|
7438
7522
|
}
|
|
7523
|
+
function migrateDotfile(serviceName) {
|
|
7524
|
+
const legacyDir = join(homedir(), `.${serviceName}`);
|
|
7525
|
+
const newDir = join(HASNA_DIR, serviceName);
|
|
7526
|
+
if (!existsSync(legacyDir))
|
|
7527
|
+
return [];
|
|
7528
|
+
if (existsSync(newDir))
|
|
7529
|
+
return [];
|
|
7530
|
+
mkdirSync(newDir, { recursive: true });
|
|
7531
|
+
const migrated = [];
|
|
7532
|
+
copyDirRecursive(legacyDir, newDir, legacyDir, migrated);
|
|
7533
|
+
return migrated;
|
|
7534
|
+
}
|
|
7535
|
+
function copyDirRecursive(src, dest, root, migrated) {
|
|
7536
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
7537
|
+
for (const entry of entries) {
|
|
7538
|
+
const srcPath = join(src, entry.name);
|
|
7539
|
+
const destPath = join(dest, entry.name);
|
|
7540
|
+
if (entry.isDirectory()) {
|
|
7541
|
+
mkdirSync(destPath, { recursive: true });
|
|
7542
|
+
copyDirRecursive(srcPath, destPath, root, migrated);
|
|
7543
|
+
} else {
|
|
7544
|
+
copyFileSync(srcPath, destPath);
|
|
7545
|
+
migrated.push(relative(root, srcPath));
|
|
7546
|
+
}
|
|
7547
|
+
}
|
|
7548
|
+
}
|
|
7549
|
+
function hasLegacyDotfile(serviceName) {
|
|
7550
|
+
return existsSync(join(homedir(), `.${serviceName}`));
|
|
7551
|
+
}
|
|
7552
|
+
function getHasnaDir() {
|
|
7553
|
+
mkdirSync(HASNA_DIR, { recursive: true });
|
|
7554
|
+
return HASNA_DIR;
|
|
7555
|
+
}
|
|
7439
7556
|
function getConfigDir() {
|
|
7440
7557
|
return CONFIG_DIR;
|
|
7441
7558
|
}
|
|
@@ -8008,6 +8125,10 @@ async function sendFeedback(feedback, db) {
|
|
|
8008
8125
|
return { sent: false, id, error: errorMsg };
|
|
8009
8126
|
}
|
|
8010
8127
|
}
|
|
8128
|
+
function listFeedback(db) {
|
|
8129
|
+
ensureFeedbackTable(db);
|
|
8130
|
+
return db.all(`SELECT id, service, version, message, email, machine_id, created_at FROM feedback ORDER BY created_at DESC`);
|
|
8131
|
+
}
|
|
8011
8132
|
|
|
8012
8133
|
class SyncProgressTracker {
|
|
8013
8134
|
db;
|
|
@@ -8128,6 +8249,847 @@ class SyncProgressTracker {
|
|
|
8128
8249
|
}
|
|
8129
8250
|
}
|
|
8130
8251
|
}
|
|
8252
|
+
function detectConflicts(local, remote, table, primaryKey = "id", conflictColumn = "updated_at") {
|
|
8253
|
+
const conflicts = [];
|
|
8254
|
+
const remoteMap = new Map;
|
|
8255
|
+
for (const row of remote) {
|
|
8256
|
+
const key = String(row[primaryKey]);
|
|
8257
|
+
remoteMap.set(key, row);
|
|
8258
|
+
}
|
|
8259
|
+
for (const localRow of local) {
|
|
8260
|
+
const key = String(localRow[primaryKey]);
|
|
8261
|
+
const remoteRow = remoteMap.get(key);
|
|
8262
|
+
if (!remoteRow)
|
|
8263
|
+
continue;
|
|
8264
|
+
const localTs = localRow[conflictColumn];
|
|
8265
|
+
const remoteTs = remoteRow[conflictColumn];
|
|
8266
|
+
if (localTs !== remoteTs) {
|
|
8267
|
+
conflicts.push({
|
|
8268
|
+
table,
|
|
8269
|
+
row_id: key,
|
|
8270
|
+
local_updated_at: String(localTs ?? ""),
|
|
8271
|
+
remote_updated_at: String(remoteTs ?? ""),
|
|
8272
|
+
local_data: { ...localRow },
|
|
8273
|
+
remote_data: { ...remoteRow },
|
|
8274
|
+
resolved: false
|
|
8275
|
+
});
|
|
8276
|
+
}
|
|
8277
|
+
}
|
|
8278
|
+
return conflicts;
|
|
8279
|
+
}
|
|
8280
|
+
function resolveConflicts(conflicts, strategy = "newest-wins") {
|
|
8281
|
+
return conflicts.map((conflict) => {
|
|
8282
|
+
const resolved = { ...conflict, resolved: true, resolution: strategy };
|
|
8283
|
+
switch (strategy) {
|
|
8284
|
+
case "local-wins":
|
|
8285
|
+
break;
|
|
8286
|
+
case "remote-wins":
|
|
8287
|
+
break;
|
|
8288
|
+
case "newest-wins": {
|
|
8289
|
+
const localTime = new Date(conflict.local_updated_at).getTime();
|
|
8290
|
+
const remoteTime = new Date(conflict.remote_updated_at).getTime();
|
|
8291
|
+
if (remoteTime > localTime) {
|
|
8292
|
+
resolved.resolution = "newest-wins";
|
|
8293
|
+
} else {
|
|
8294
|
+
resolved.resolution = "newest-wins";
|
|
8295
|
+
}
|
|
8296
|
+
break;
|
|
8297
|
+
}
|
|
8298
|
+
}
|
|
8299
|
+
return resolved;
|
|
8300
|
+
});
|
|
8301
|
+
}
|
|
8302
|
+
function getWinningData(conflict) {
|
|
8303
|
+
if (!conflict.resolved || !conflict.resolution) {
|
|
8304
|
+
throw new Error(`Conflict for row ${conflict.row_id} is not resolved`);
|
|
8305
|
+
}
|
|
8306
|
+
switch (conflict.resolution) {
|
|
8307
|
+
case "local-wins":
|
|
8308
|
+
return conflict.local_data;
|
|
8309
|
+
case "remote-wins":
|
|
8310
|
+
return conflict.remote_data;
|
|
8311
|
+
case "newest-wins": {
|
|
8312
|
+
const localTime = new Date(conflict.local_updated_at).getTime();
|
|
8313
|
+
const remoteTime = new Date(conflict.remote_updated_at).getTime();
|
|
8314
|
+
return remoteTime >= localTime ? conflict.remote_data : conflict.local_data;
|
|
8315
|
+
}
|
|
8316
|
+
case "manual":
|
|
8317
|
+
return conflict.local_data;
|
|
8318
|
+
default:
|
|
8319
|
+
return conflict.local_data;
|
|
8320
|
+
}
|
|
8321
|
+
}
|
|
8322
|
+
function ensureConflictsTable(db) {
|
|
8323
|
+
db.exec(`
|
|
8324
|
+
CREATE TABLE IF NOT EXISTS _sync_conflicts (
|
|
8325
|
+
id TEXT PRIMARY KEY,
|
|
8326
|
+
table_name TEXT,
|
|
8327
|
+
row_id TEXT,
|
|
8328
|
+
local_data TEXT,
|
|
8329
|
+
remote_data TEXT,
|
|
8330
|
+
local_updated_at TEXT,
|
|
8331
|
+
remote_updated_at TEXT,
|
|
8332
|
+
resolution TEXT,
|
|
8333
|
+
resolved_at TEXT,
|
|
8334
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
8335
|
+
)
|
|
8336
|
+
`);
|
|
8337
|
+
}
|
|
8338
|
+
function storeConflicts(db, conflicts) {
|
|
8339
|
+
ensureConflictsTable(db);
|
|
8340
|
+
for (const conflict of conflicts) {
|
|
8341
|
+
const id = `${conflict.table}:${conflict.row_id}:${Date.now()}`;
|
|
8342
|
+
db.run(`INSERT INTO _sync_conflicts (id, table_name, row_id, local_data, remote_data, local_updated_at, remote_updated_at, resolution, resolved_at)
|
|
8343
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, conflict.table, conflict.row_id, JSON.stringify(conflict.local_data), JSON.stringify(conflict.remote_data), conflict.local_updated_at, conflict.remote_updated_at, conflict.resolution ?? null, conflict.resolved ? new Date().toISOString() : null);
|
|
8344
|
+
}
|
|
8345
|
+
}
|
|
8346
|
+
function listConflicts(db, opts) {
|
|
8347
|
+
ensureConflictsTable(db);
|
|
8348
|
+
let sql = `SELECT * FROM _sync_conflicts WHERE 1=1`;
|
|
8349
|
+
const params = [];
|
|
8350
|
+
if (opts?.resolved !== undefined) {
|
|
8351
|
+
if (opts.resolved) {
|
|
8352
|
+
sql += ` AND resolution IS NOT NULL AND resolved_at IS NOT NULL`;
|
|
8353
|
+
} else {
|
|
8354
|
+
sql += ` AND (resolution IS NULL OR resolved_at IS NULL)`;
|
|
8355
|
+
}
|
|
8356
|
+
}
|
|
8357
|
+
if (opts?.table) {
|
|
8358
|
+
sql += ` AND table_name = ?`;
|
|
8359
|
+
params.push(opts.table);
|
|
8360
|
+
}
|
|
8361
|
+
sql += ` ORDER BY created_at DESC`;
|
|
8362
|
+
return db.all(sql, ...params);
|
|
8363
|
+
}
|
|
8364
|
+
function resolveConflict(db, conflictId, strategy) {
|
|
8365
|
+
ensureConflictsTable(db);
|
|
8366
|
+
const row = db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
|
|
8367
|
+
if (!row)
|
|
8368
|
+
return null;
|
|
8369
|
+
db.run(`UPDATE _sync_conflicts SET resolution = ?, resolved_at = datetime('now') WHERE id = ?`, strategy, conflictId);
|
|
8370
|
+
return db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
|
|
8371
|
+
}
|
|
8372
|
+
function getConflict(db, conflictId) {
|
|
8373
|
+
ensureConflictsTable(db);
|
|
8374
|
+
return db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
|
|
8375
|
+
}
|
|
8376
|
+
function purgeResolvedConflicts(db) {
|
|
8377
|
+
ensureConflictsTable(db);
|
|
8378
|
+
const result = db.run(`DELETE FROM _sync_conflicts WHERE resolution IS NOT NULL AND resolved_at IS NOT NULL`);
|
|
8379
|
+
return result.changes;
|
|
8380
|
+
}
|
|
8381
|
+
function ensureSyncMetaTable(db) {
|
|
8382
|
+
db.exec(SYNC_META_TABLE_SQL);
|
|
8383
|
+
}
|
|
8384
|
+
function getSyncMeta(db, table) {
|
|
8385
|
+
ensureSyncMetaTable(db);
|
|
8386
|
+
return db.get(`SELECT table_name, last_synced_at, last_synced_row_count, direction FROM _sync_meta WHERE table_name = ?`, table) ?? null;
|
|
8387
|
+
}
|
|
8388
|
+
function upsertSyncMeta(db, meta3) {
|
|
8389
|
+
ensureSyncMetaTable(db);
|
|
8390
|
+
const existing = db.get(`SELECT table_name FROM _sync_meta WHERE table_name = ?`, meta3.table_name);
|
|
8391
|
+
if (existing) {
|
|
8392
|
+
db.run(`UPDATE _sync_meta SET last_synced_at = ?, last_synced_row_count = ?, direction = ? WHERE table_name = ?`, meta3.last_synced_at, meta3.last_synced_row_count, meta3.direction, meta3.table_name);
|
|
8393
|
+
} else {
|
|
8394
|
+
db.run(`INSERT INTO _sync_meta (table_name, last_synced_at, last_synced_row_count, direction) VALUES (?, ?, ?, ?)`, meta3.table_name, meta3.last_synced_at, meta3.last_synced_row_count, meta3.direction);
|
|
8395
|
+
}
|
|
8396
|
+
}
|
|
8397
|
+
function transferRows(source, target, table, rows, options) {
|
|
8398
|
+
const { primaryKey = "id", conflictColumn = "updated_at" } = options;
|
|
8399
|
+
let written = 0;
|
|
8400
|
+
let skipped = 0;
|
|
8401
|
+
const errors22 = [];
|
|
8402
|
+
if (rows.length === 0)
|
|
8403
|
+
return { written, skipped, errors: errors22 };
|
|
8404
|
+
const columns = Object.keys(rows[0]);
|
|
8405
|
+
const hasConflictCol = columns.includes(conflictColumn);
|
|
8406
|
+
const hasPrimaryKey = columns.includes(primaryKey);
|
|
8407
|
+
if (!hasPrimaryKey) {
|
|
8408
|
+
errors22.push(`Table "${table}" has no "${primaryKey}" column -- skipping`);
|
|
8409
|
+
return { written, skipped, errors: errors22 };
|
|
8410
|
+
}
|
|
8411
|
+
for (const row of rows) {
|
|
8412
|
+
try {
|
|
8413
|
+
const existing = target.get(`SELECT "${primaryKey}"${hasConflictCol ? `, "${conflictColumn}"` : ""} FROM "${table}" WHERE "${primaryKey}" = ?`, row[primaryKey]);
|
|
8414
|
+
if (existing) {
|
|
8415
|
+
if (hasConflictCol && existing[conflictColumn] && row[conflictColumn]) {
|
|
8416
|
+
const existingTime = new Date(existing[conflictColumn]).getTime();
|
|
8417
|
+
const incomingTime = new Date(row[conflictColumn]).getTime();
|
|
8418
|
+
if (existingTime >= incomingTime) {
|
|
8419
|
+
skipped++;
|
|
8420
|
+
continue;
|
|
8421
|
+
}
|
|
8422
|
+
}
|
|
8423
|
+
const setClauses = columns.filter((c) => c !== primaryKey).map((c) => `"${c}" = ?`).join(", ");
|
|
8424
|
+
const values = columns.filter((c) => c !== primaryKey).map((c) => row[c]);
|
|
8425
|
+
values.push(row[primaryKey]);
|
|
8426
|
+
target.run(`UPDATE "${table}" SET ${setClauses} WHERE "${primaryKey}" = ?`, ...values);
|
|
8427
|
+
} else {
|
|
8428
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
8429
|
+
const colList = columns.map((c) => `"${c}"`).join(", ");
|
|
8430
|
+
const values = columns.map((c) => row[c]);
|
|
8431
|
+
target.run(`INSERT INTO "${table}" (${colList}) VALUES (${placeholders})`, ...values);
|
|
8432
|
+
}
|
|
8433
|
+
written++;
|
|
8434
|
+
} catch (err) {
|
|
8435
|
+
errors22.push(`Row ${row[primaryKey]}: ${err?.message ?? String(err)}`);
|
|
8436
|
+
}
|
|
8437
|
+
}
|
|
8438
|
+
return { written, skipped, errors: errors22 };
|
|
8439
|
+
}
|
|
8440
|
+
function incrementalSyncPush(local, remote, tables, options = {}) {
|
|
8441
|
+
const { conflictColumn = "updated_at", batchSize = 500 } = options;
|
|
8442
|
+
const results = [];
|
|
8443
|
+
ensureSyncMetaTable(local);
|
|
8444
|
+
for (const table of tables) {
|
|
8445
|
+
const stat = {
|
|
8446
|
+
table,
|
|
8447
|
+
total_rows: 0,
|
|
8448
|
+
synced_rows: 0,
|
|
8449
|
+
skipped_rows: 0,
|
|
8450
|
+
errors: [],
|
|
8451
|
+
first_sync: false
|
|
8452
|
+
};
|
|
8453
|
+
try {
|
|
8454
|
+
const countResult = local.get(`SELECT COUNT(*) as cnt FROM "${table}"`);
|
|
8455
|
+
stat.total_rows = countResult?.cnt ?? 0;
|
|
8456
|
+
const meta3 = getSyncMeta(local, table);
|
|
8457
|
+
let rows;
|
|
8458
|
+
if (meta3?.last_synced_at) {
|
|
8459
|
+
try {
|
|
8460
|
+
rows = local.all(`SELECT * FROM "${table}" WHERE "${conflictColumn}" > ?`, meta3.last_synced_at);
|
|
8461
|
+
} catch {
|
|
8462
|
+
rows = local.all(`SELECT * FROM "${table}"`);
|
|
8463
|
+
stat.first_sync = true;
|
|
8464
|
+
}
|
|
8465
|
+
} else {
|
|
8466
|
+
rows = local.all(`SELECT * FROM "${table}"`);
|
|
8467
|
+
stat.first_sync = true;
|
|
8468
|
+
}
|
|
8469
|
+
for (let offset = 0;offset < rows.length; offset += batchSize) {
|
|
8470
|
+
const batch = rows.slice(offset, offset + batchSize);
|
|
8471
|
+
const result = transferRows(local, remote, table, batch, options);
|
|
8472
|
+
stat.synced_rows += result.written;
|
|
8473
|
+
stat.skipped_rows += result.skipped;
|
|
8474
|
+
stat.errors.push(...result.errors);
|
|
8475
|
+
}
|
|
8476
|
+
if (rows.length === 0) {
|
|
8477
|
+
stat.skipped_rows = stat.total_rows;
|
|
8478
|
+
}
|
|
8479
|
+
const now = new Date().toISOString();
|
|
8480
|
+
upsertSyncMeta(local, {
|
|
8481
|
+
table_name: table,
|
|
8482
|
+
last_synced_at: now,
|
|
8483
|
+
last_synced_row_count: stat.synced_rows,
|
|
8484
|
+
direction: "push"
|
|
8485
|
+
});
|
|
8486
|
+
} catch (err) {
|
|
8487
|
+
stat.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
|
|
8488
|
+
}
|
|
8489
|
+
results.push(stat);
|
|
8490
|
+
}
|
|
8491
|
+
return results;
|
|
8492
|
+
}
|
|
8493
|
+
function incrementalSyncPull(remote, local, tables, options = {}) {
|
|
8494
|
+
const { conflictColumn = "updated_at", batchSize = 500 } = options;
|
|
8495
|
+
const results = [];
|
|
8496
|
+
ensureSyncMetaTable(local);
|
|
8497
|
+
for (const table of tables) {
|
|
8498
|
+
const stat = {
|
|
8499
|
+
table,
|
|
8500
|
+
total_rows: 0,
|
|
8501
|
+
synced_rows: 0,
|
|
8502
|
+
skipped_rows: 0,
|
|
8503
|
+
errors: [],
|
|
8504
|
+
first_sync: false
|
|
8505
|
+
};
|
|
8506
|
+
try {
|
|
8507
|
+
const countResult = remote.get(`SELECT COUNT(*) as cnt FROM "${table}"`);
|
|
8508
|
+
stat.total_rows = countResult?.cnt ?? 0;
|
|
8509
|
+
const meta3 = getSyncMeta(local, table);
|
|
8510
|
+
let rows;
|
|
8511
|
+
if (meta3?.last_synced_at) {
|
|
8512
|
+
try {
|
|
8513
|
+
rows = remote.all(`SELECT * FROM "${table}" WHERE "${conflictColumn}" > ?`, meta3.last_synced_at);
|
|
8514
|
+
} catch {
|
|
8515
|
+
rows = remote.all(`SELECT * FROM "${table}"`);
|
|
8516
|
+
stat.first_sync = true;
|
|
8517
|
+
}
|
|
8518
|
+
} else {
|
|
8519
|
+
rows = remote.all(`SELECT * FROM "${table}"`);
|
|
8520
|
+
stat.first_sync = true;
|
|
8521
|
+
}
|
|
8522
|
+
for (let offset = 0;offset < rows.length; offset += batchSize) {
|
|
8523
|
+
const batch = rows.slice(offset, offset + batchSize);
|
|
8524
|
+
const result = transferRows(remote, local, table, batch, options);
|
|
8525
|
+
stat.synced_rows += result.written;
|
|
8526
|
+
stat.skipped_rows += result.skipped;
|
|
8527
|
+
stat.errors.push(...result.errors);
|
|
8528
|
+
}
|
|
8529
|
+
if (rows.length === 0) {
|
|
8530
|
+
stat.skipped_rows = stat.total_rows;
|
|
8531
|
+
}
|
|
8532
|
+
const now = new Date().toISOString();
|
|
8533
|
+
upsertSyncMeta(local, {
|
|
8534
|
+
table_name: table,
|
|
8535
|
+
last_synced_at: now,
|
|
8536
|
+
last_synced_row_count: stat.synced_rows,
|
|
8537
|
+
direction: "pull"
|
|
8538
|
+
});
|
|
8539
|
+
} catch (err) {
|
|
8540
|
+
stat.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
|
|
8541
|
+
}
|
|
8542
|
+
results.push(stat);
|
|
8543
|
+
}
|
|
8544
|
+
return results;
|
|
8545
|
+
}
|
|
8546
|
+
function getSyncMetaAll(db) {
|
|
8547
|
+
ensureSyncMetaTable(db);
|
|
8548
|
+
return db.all(`SELECT table_name, last_synced_at, last_synced_row_count, direction FROM _sync_meta ORDER BY table_name`);
|
|
8549
|
+
}
|
|
8550
|
+
function getSyncMetaForTable(db, table) {
|
|
8551
|
+
return getSyncMeta(db, table);
|
|
8552
|
+
}
|
|
8553
|
+
function resetSyncMeta(db, table) {
|
|
8554
|
+
ensureSyncMetaTable(db);
|
|
8555
|
+
db.run(`DELETE FROM _sync_meta WHERE table_name = ?`, table);
|
|
8556
|
+
}
|
|
8557
|
+
function resetAllSyncMeta(db) {
|
|
8558
|
+
ensureSyncMetaTable(db);
|
|
8559
|
+
db.run(`DELETE FROM _sync_meta`);
|
|
8560
|
+
}
|
|
8561
|
+
function getAutoSyncConfig() {
|
|
8562
|
+
try {
|
|
8563
|
+
if (!existsSync4(AUTO_SYNC_CONFIG_PATH)) {
|
|
8564
|
+
return { ...DEFAULT_AUTO_SYNC_CONFIG };
|
|
8565
|
+
}
|
|
8566
|
+
const raw = JSON.parse(readFileSync2(AUTO_SYNC_CONFIG_PATH, "utf-8"));
|
|
8567
|
+
return {
|
|
8568
|
+
auto_sync_on_start: typeof raw.auto_sync_on_start === "boolean" ? raw.auto_sync_on_start : DEFAULT_AUTO_SYNC_CONFIG.auto_sync_on_start,
|
|
8569
|
+
auto_sync_on_stop: typeof raw.auto_sync_on_stop === "boolean" ? raw.auto_sync_on_stop : DEFAULT_AUTO_SYNC_CONFIG.auto_sync_on_stop
|
|
8570
|
+
};
|
|
8571
|
+
} catch {
|
|
8572
|
+
return { ...DEFAULT_AUTO_SYNC_CONFIG };
|
|
8573
|
+
}
|
|
8574
|
+
}
|
|
8575
|
+
async function executeAutoSync(event, serviceName, local, tables) {
|
|
8576
|
+
const direction = event === "start" ? "pull" : "push";
|
|
8577
|
+
const result = {
|
|
8578
|
+
event,
|
|
8579
|
+
direction,
|
|
8580
|
+
success: false,
|
|
8581
|
+
tables_synced: 0,
|
|
8582
|
+
total_rows_synced: 0,
|
|
8583
|
+
errors: []
|
|
8584
|
+
};
|
|
8585
|
+
let remote = null;
|
|
8586
|
+
try {
|
|
8587
|
+
const connStr = getConnectionString(serviceName);
|
|
8588
|
+
remote = new PgAdapterAsync(connStr);
|
|
8589
|
+
const syncTables = tables.length > 0 ? tables.filter((t) => !isSyncExcludedTable(t)) : direction === "push" ? listSqliteTables(local).filter((t) => !isSyncExcludedTable(t)) : (await listPgTables(remote)).filter((t) => !isSyncExcludedTable(t));
|
|
8590
|
+
if (syncTables.length === 0) {
|
|
8591
|
+
result.success = true;
|
|
8592
|
+
return result;
|
|
8593
|
+
}
|
|
8594
|
+
const results = direction === "pull" ? await syncPull(remote, local, { tables: syncTables }) : await syncPush(local, remote, { tables: syncTables });
|
|
8595
|
+
for (const r of results) {
|
|
8596
|
+
if (r.errors.length === 0)
|
|
8597
|
+
result.tables_synced++;
|
|
8598
|
+
result.total_rows_synced += r.rowsWritten;
|
|
8599
|
+
result.errors.push(...r.errors);
|
|
8600
|
+
}
|
|
8601
|
+
result.success = result.errors.length === 0;
|
|
8602
|
+
} catch (err) {
|
|
8603
|
+
result.errors.push(err?.message ?? String(err));
|
|
8604
|
+
} finally {
|
|
8605
|
+
if (remote) {
|
|
8606
|
+
try {
|
|
8607
|
+
await remote.close();
|
|
8608
|
+
} catch {}
|
|
8609
|
+
}
|
|
8610
|
+
}
|
|
8611
|
+
return result;
|
|
8612
|
+
}
|
|
8613
|
+
function installSignalHandlers() {
|
|
8614
|
+
if (signalHandlersInstalled)
|
|
8615
|
+
return;
|
|
8616
|
+
signalHandlersInstalled = true;
|
|
8617
|
+
const handleExit = async () => {
|
|
8618
|
+
for (const fn of cleanupHandlers) {
|
|
8619
|
+
try {
|
|
8620
|
+
await fn();
|
|
8621
|
+
} catch {}
|
|
8622
|
+
}
|
|
8623
|
+
};
|
|
8624
|
+
process.on("SIGTERM", async () => {
|
|
8625
|
+
await handleExit();
|
|
8626
|
+
process.exit(0);
|
|
8627
|
+
});
|
|
8628
|
+
process.on("SIGINT", async () => {
|
|
8629
|
+
await handleExit();
|
|
8630
|
+
process.exit(0);
|
|
8631
|
+
});
|
|
8632
|
+
process.on("beforeExit", async () => {
|
|
8633
|
+
await handleExit();
|
|
8634
|
+
});
|
|
8635
|
+
}
|
|
8636
|
+
function setupAutoSync(serviceName, server, local, remote, tables) {
|
|
8637
|
+
const config2 = getAutoSyncConfig();
|
|
8638
|
+
const cloudConfig = getCloudConfig();
|
|
8639
|
+
const isSyncEnabled = cloudConfig.mode === "hybrid" || cloudConfig.mode === "cloud";
|
|
8640
|
+
const syncOnStart = async () => {
|
|
8641
|
+
if (!config2.auto_sync_on_start || !isSyncEnabled)
|
|
8642
|
+
return null;
|
|
8643
|
+
return executeAutoSync("start", serviceName, local, tables);
|
|
8644
|
+
};
|
|
8645
|
+
const syncOnStop = async () => {
|
|
8646
|
+
if (!config2.auto_sync_on_stop || !isSyncEnabled)
|
|
8647
|
+
return null;
|
|
8648
|
+
return executeAutoSync("stop", serviceName, local, tables);
|
|
8649
|
+
};
|
|
8650
|
+
if (server && typeof server.onconnect === "function") {
|
|
8651
|
+
const origOnConnect = server.onconnect;
|
|
8652
|
+
server.onconnect = async (...args) => {
|
|
8653
|
+
await syncOnStart();
|
|
8654
|
+
return origOnConnect.apply(server, args);
|
|
8655
|
+
};
|
|
8656
|
+
} else if (server && typeof server.on === "function") {
|
|
8657
|
+
server.on("connect", () => syncOnStart());
|
|
8658
|
+
}
|
|
8659
|
+
if (server && typeof server.ondisconnect === "function") {
|
|
8660
|
+
const origOnDisconnect = server.ondisconnect;
|
|
8661
|
+
server.ondisconnect = async (...args) => {
|
|
8662
|
+
await syncOnStop();
|
|
8663
|
+
return origOnDisconnect.apply(server, args);
|
|
8664
|
+
};
|
|
8665
|
+
} else if (server && typeof server.on === "function") {
|
|
8666
|
+
server.on("disconnect", () => syncOnStop());
|
|
8667
|
+
}
|
|
8668
|
+
installSignalHandlers();
|
|
8669
|
+
cleanupHandlers.push(async () => {
|
|
8670
|
+
await syncOnStop();
|
|
8671
|
+
});
|
|
8672
|
+
return { syncOnStart, syncOnStop, config: config2 };
|
|
8673
|
+
}
|
|
8674
|
+
function enableAutoSync(serviceName, mcpServer, local, remote, tables) {
|
|
8675
|
+
setupAutoSync(serviceName, mcpServer, local, remote, tables);
|
|
8676
|
+
}
|
|
8677
|
+
function discoverSyncableServices2() {
|
|
8678
|
+
const hasnaDir = getHasnaDir();
|
|
8679
|
+
const services = [];
|
|
8680
|
+
try {
|
|
8681
|
+
const entries = readdirSync3(hasnaDir, { withFileTypes: true });
|
|
8682
|
+
for (const entry of entries) {
|
|
8683
|
+
if (!entry.isDirectory())
|
|
8684
|
+
continue;
|
|
8685
|
+
const dbPath = join5(hasnaDir, entry.name, `${entry.name}.db`);
|
|
8686
|
+
if (existsSync5(dbPath)) {
|
|
8687
|
+
services.push(entry.name);
|
|
8688
|
+
}
|
|
8689
|
+
}
|
|
8690
|
+
} catch {}
|
|
8691
|
+
return services;
|
|
8692
|
+
}
|
|
8693
|
+
async function runScheduledSync() {
|
|
8694
|
+
const config2 = getCloudConfig();
|
|
8695
|
+
if (config2.mode === "local")
|
|
8696
|
+
return [];
|
|
8697
|
+
const services = discoverSyncableServices2();
|
|
8698
|
+
const results = [];
|
|
8699
|
+
let remote = null;
|
|
8700
|
+
for (const service of services) {
|
|
8701
|
+
const result = {
|
|
8702
|
+
service,
|
|
8703
|
+
tables_synced: 0,
|
|
8704
|
+
total_rows_synced: 0,
|
|
8705
|
+
errors: []
|
|
8706
|
+
};
|
|
8707
|
+
try {
|
|
8708
|
+
const dbPath = join5(getDataDir(service), `${service}.db`);
|
|
8709
|
+
if (!existsSync5(dbPath)) {
|
|
8710
|
+
continue;
|
|
8711
|
+
}
|
|
8712
|
+
const local = new SqliteAdapter(dbPath);
|
|
8713
|
+
const tables = listSqliteTables(local).filter((t) => !t.startsWith("_") && !t.startsWith("sqlite_"));
|
|
8714
|
+
if (tables.length === 0) {
|
|
8715
|
+
local.close();
|
|
8716
|
+
continue;
|
|
8717
|
+
}
|
|
8718
|
+
try {
|
|
8719
|
+
const connStr = getConnectionString(service);
|
|
8720
|
+
remote = new PgAdapterAsync(connStr);
|
|
8721
|
+
} catch (err) {
|
|
8722
|
+
result.errors.push(`Connection failed: ${err?.message ?? String(err)}`);
|
|
8723
|
+
local.close();
|
|
8724
|
+
results.push(result);
|
|
8725
|
+
continue;
|
|
8726
|
+
}
|
|
8727
|
+
const stats = incrementalSyncPush(local, remote, tables);
|
|
8728
|
+
for (const s of stats) {
|
|
8729
|
+
if (s.errors.length === 0) {
|
|
8730
|
+
result.tables_synced++;
|
|
8731
|
+
}
|
|
8732
|
+
result.total_rows_synced += s.synced_rows;
|
|
8733
|
+
result.errors.push(...s.errors);
|
|
8734
|
+
}
|
|
8735
|
+
local.close();
|
|
8736
|
+
await remote.close();
|
|
8737
|
+
remote = null;
|
|
8738
|
+
} catch (err) {
|
|
8739
|
+
result.errors.push(err?.message ?? String(err));
|
|
8740
|
+
}
|
|
8741
|
+
results.push(result);
|
|
8742
|
+
}
|
|
8743
|
+
if (remote) {
|
|
8744
|
+
try {
|
|
8745
|
+
await remote.close();
|
|
8746
|
+
} catch {}
|
|
8747
|
+
}
|
|
8748
|
+
return results;
|
|
8749
|
+
}
|
|
8750
|
+
function parseInterval(input) {
|
|
8751
|
+
const trimmed = input.trim().toLowerCase();
|
|
8752
|
+
const hourMatch = trimmed.match(/^(\d+)\s*h$/);
|
|
8753
|
+
if (hourMatch) {
|
|
8754
|
+
const hours = parseInt(hourMatch[1], 10);
|
|
8755
|
+
if (hours <= 0) {
|
|
8756
|
+
throw new Error(`Invalid interval "${input}". Value must be greater than 0.`);
|
|
8757
|
+
}
|
|
8758
|
+
return hours * 60;
|
|
8759
|
+
}
|
|
8760
|
+
const minMatch = trimmed.match(/^(\d+)\s*m$/);
|
|
8761
|
+
if (minMatch) {
|
|
8762
|
+
const mins = parseInt(minMatch[1], 10);
|
|
8763
|
+
if (mins <= 0) {
|
|
8764
|
+
throw new Error(`Invalid interval "${input}". Value must be greater than 0.`);
|
|
8765
|
+
}
|
|
8766
|
+
return mins;
|
|
8767
|
+
}
|
|
8768
|
+
const plain = parseInt(trimmed, 10);
|
|
8769
|
+
if (!isNaN(plain) && plain > 0) {
|
|
8770
|
+
return plain;
|
|
8771
|
+
}
|
|
8772
|
+
throw new Error(`Invalid interval "${input}". Use formats like: 5m, 10m, 1h, or a plain number of minutes.`);
|
|
8773
|
+
}
|
|
8774
|
+
function minutesToCron(minutes) {
|
|
8775
|
+
if (minutes <= 0) {
|
|
8776
|
+
throw new Error("Interval must be greater than 0 minutes.");
|
|
8777
|
+
}
|
|
8778
|
+
if (minutes < 60) {
|
|
8779
|
+
return `*/${minutes} * * * *`;
|
|
8780
|
+
}
|
|
8781
|
+
const hours = Math.floor(minutes / 60);
|
|
8782
|
+
const remainderMins = minutes % 60;
|
|
8783
|
+
if (remainderMins === 0 && hours <= 24) {
|
|
8784
|
+
return `0 */${hours} * * *`;
|
|
8785
|
+
}
|
|
8786
|
+
return `*/${minutes} * * * *`;
|
|
8787
|
+
}
|
|
8788
|
+
function getWorkerPath() {
|
|
8789
|
+
const dir = typeof import.meta.dir === "string" ? import.meta.dir : dirname(import.meta.url.replace("file://", ""));
|
|
8790
|
+
const tsPath = join6(dir, "scheduled-sync.ts");
|
|
8791
|
+
const jsPath = join6(dir, "scheduled-sync.js");
|
|
8792
|
+
try {
|
|
8793
|
+
if (existsSync6(tsPath))
|
|
8794
|
+
return tsPath;
|
|
8795
|
+
} catch {}
|
|
8796
|
+
return jsPath;
|
|
8797
|
+
}
|
|
8798
|
+
function getBunPath() {
|
|
8799
|
+
const candidates = [
|
|
8800
|
+
join6(homedir5(), ".bun", "bin", "bun"),
|
|
8801
|
+
"/usr/local/bin/bun",
|
|
8802
|
+
"/usr/bin/bun"
|
|
8803
|
+
];
|
|
8804
|
+
for (const p of candidates) {
|
|
8805
|
+
if (existsSync6(p))
|
|
8806
|
+
return p;
|
|
8807
|
+
}
|
|
8808
|
+
return "bun";
|
|
8809
|
+
}
|
|
8810
|
+
function getLaunchdPlistPath() {
|
|
8811
|
+
return join6(homedir5(), "Library", "LaunchAgents", `com.hasna.cloud-sync.plist`);
|
|
8812
|
+
}
|
|
8813
|
+
function createLaunchdPlist(intervalMinutes) {
|
|
8814
|
+
const workerPath = getWorkerPath();
|
|
8815
|
+
const bunPath = getBunPath();
|
|
8816
|
+
const logPath = join6(CONFIG_DIR2, "sync.log");
|
|
8817
|
+
const errorLogPath = join6(CONFIG_DIR2, "sync-error.log");
|
|
8818
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
8819
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
8820
|
+
<plist version="1.0">
|
|
8821
|
+
<dict>
|
|
8822
|
+
<key>Label</key>
|
|
8823
|
+
<string>com.hasna.cloud-sync</string>
|
|
8824
|
+
<key>ProgramArguments</key>
|
|
8825
|
+
<array>
|
|
8826
|
+
<string>${bunPath}</string>
|
|
8827
|
+
<string>run</string>
|
|
8828
|
+
<string>${workerPath}</string>
|
|
8829
|
+
</array>
|
|
8830
|
+
<key>StartInterval</key>
|
|
8831
|
+
<integer>${intervalMinutes * 60}</integer>
|
|
8832
|
+
<key>RunAtLoad</key>
|
|
8833
|
+
<true/>
|
|
8834
|
+
<key>StandardOutPath</key>
|
|
8835
|
+
<string>${logPath}</string>
|
|
8836
|
+
<key>StandardErrorPath</key>
|
|
8837
|
+
<string>${errorLogPath}</string>
|
|
8838
|
+
<key>EnvironmentVariables</key>
|
|
8839
|
+
<dict>
|
|
8840
|
+
<key>PATH</key>
|
|
8841
|
+
<string>${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}</string>
|
|
8842
|
+
<key>HOME</key>
|
|
8843
|
+
<string>${homedir5()}</string>
|
|
8844
|
+
</dict>
|
|
8845
|
+
</dict>
|
|
8846
|
+
</plist>`;
|
|
8847
|
+
}
|
|
8848
|
+
async function registerLaunchd(intervalMinutes) {
|
|
8849
|
+
const plistPath = getLaunchdPlistPath();
|
|
8850
|
+
const plistDir = dirname(plistPath);
|
|
8851
|
+
mkdirSync3(plistDir, { recursive: true });
|
|
8852
|
+
try {
|
|
8853
|
+
await Bun.spawn(["launchctl", "unload", plistPath]).exited;
|
|
8854
|
+
} catch {}
|
|
8855
|
+
writeFileSync2(plistPath, createLaunchdPlist(intervalMinutes));
|
|
8856
|
+
await Bun.spawn(["launchctl", "load", plistPath]).exited;
|
|
8857
|
+
}
|
|
8858
|
+
async function removeLaunchd() {
|
|
8859
|
+
const plistPath = getLaunchdPlistPath();
|
|
8860
|
+
try {
|
|
8861
|
+
await Bun.spawn(["launchctl", "unload", plistPath]).exited;
|
|
8862
|
+
} catch {}
|
|
8863
|
+
try {
|
|
8864
|
+
unlinkSync(plistPath);
|
|
8865
|
+
} catch {}
|
|
8866
|
+
}
|
|
8867
|
+
function getSystemdDir() {
|
|
8868
|
+
return join6(homedir5(), ".config", "systemd", "user");
|
|
8869
|
+
}
|
|
8870
|
+
function createSystemdService() {
|
|
8871
|
+
const workerPath = getWorkerPath();
|
|
8872
|
+
const bunPath = getBunPath();
|
|
8873
|
+
return `[Unit]
|
|
8874
|
+
Description=Hasna Cloud Sync
|
|
8875
|
+
After=network.target
|
|
8876
|
+
|
|
8877
|
+
[Service]
|
|
8878
|
+
Type=oneshot
|
|
8879
|
+
ExecStart=${bunPath} run ${workerPath}
|
|
8880
|
+
Environment=HOME=${homedir5()}
|
|
8881
|
+
Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
|
|
8882
|
+
|
|
8883
|
+
[Install]
|
|
8884
|
+
WantedBy=default.target
|
|
8885
|
+
`;
|
|
8886
|
+
}
|
|
8887
|
+
function createSystemdTimer(intervalMinutes) {
|
|
8888
|
+
return `[Unit]
|
|
8889
|
+
Description=Hasna Cloud Sync Timer
|
|
8890
|
+
|
|
8891
|
+
[Timer]
|
|
8892
|
+
OnBootSec=${intervalMinutes}min
|
|
8893
|
+
OnUnitActiveSec=${intervalMinutes}min
|
|
8894
|
+
Persistent=true
|
|
8895
|
+
|
|
8896
|
+
[Install]
|
|
8897
|
+
WantedBy=timers.target
|
|
8898
|
+
`;
|
|
8899
|
+
}
|
|
8900
|
+
async function registerSystemd(intervalMinutes) {
|
|
8901
|
+
const dir = getSystemdDir();
|
|
8902
|
+
mkdirSync3(dir, { recursive: true });
|
|
8903
|
+
writeFileSync2(join6(dir, `${SERVICE_NAME}.service`), createSystemdService());
|
|
8904
|
+
writeFileSync2(join6(dir, `${SERVICE_NAME}.timer`), createSystemdTimer(intervalMinutes));
|
|
8905
|
+
await Bun.spawn(["systemctl", "--user", "daemon-reload"]).exited;
|
|
8906
|
+
await Bun.spawn(["systemctl", "--user", "enable", "--now", `${SERVICE_NAME}.timer`]).exited;
|
|
8907
|
+
}
|
|
8908
|
+
async function removeSystemd() {
|
|
8909
|
+
try {
|
|
8910
|
+
await Bun.spawn(["systemctl", "--user", "disable", "--now", `${SERVICE_NAME}.timer`]).exited;
|
|
8911
|
+
} catch {}
|
|
8912
|
+
const dir = getSystemdDir();
|
|
8913
|
+
try {
|
|
8914
|
+
unlinkSync(join6(dir, `${SERVICE_NAME}.service`));
|
|
8915
|
+
} catch {}
|
|
8916
|
+
try {
|
|
8917
|
+
unlinkSync(join6(dir, `${SERVICE_NAME}.timer`));
|
|
8918
|
+
} catch {}
|
|
8919
|
+
try {
|
|
8920
|
+
await Bun.spawn(["systemctl", "--user", "daemon-reload"]).exited;
|
|
8921
|
+
} catch {}
|
|
8922
|
+
}
|
|
8923
|
+
async function registerSyncSchedule(intervalMinutes) {
|
|
8924
|
+
if (intervalMinutes <= 0) {
|
|
8925
|
+
throw new Error("Interval must be a positive number of minutes.");
|
|
8926
|
+
}
|
|
8927
|
+
mkdirSync3(CONFIG_DIR2, { recursive: true });
|
|
8928
|
+
if (platform() === "darwin") {
|
|
8929
|
+
await registerLaunchd(intervalMinutes);
|
|
8930
|
+
} else {
|
|
8931
|
+
await registerSystemd(intervalMinutes);
|
|
8932
|
+
}
|
|
8933
|
+
const config2 = getCloudConfig();
|
|
8934
|
+
config2.sync.schedule_minutes = intervalMinutes;
|
|
8935
|
+
saveCloudConfig(config2);
|
|
8936
|
+
}
|
|
8937
|
+
async function removeSyncSchedule() {
|
|
8938
|
+
if (platform() === "darwin") {
|
|
8939
|
+
await removeLaunchd();
|
|
8940
|
+
} else {
|
|
8941
|
+
await removeSystemd();
|
|
8942
|
+
}
|
|
8943
|
+
const config2 = getCloudConfig();
|
|
8944
|
+
config2.sync.schedule_minutes = 0;
|
|
8945
|
+
saveCloudConfig(config2);
|
|
8946
|
+
}
|
|
8947
|
+
function getSyncScheduleStatus() {
|
|
8948
|
+
const config2 = getCloudConfig();
|
|
8949
|
+
const minutes = config2.sync.schedule_minutes;
|
|
8950
|
+
const registered = minutes > 0;
|
|
8951
|
+
let mechanism = "none";
|
|
8952
|
+
if (registered) {
|
|
8953
|
+
if (platform() === "darwin") {
|
|
8954
|
+
mechanism = existsSync6(getLaunchdPlistPath()) ? "launchd" : "none";
|
|
8955
|
+
} else {
|
|
8956
|
+
mechanism = existsSync6(join6(getSystemdDir(), `${SERVICE_NAME}.timer`)) ? "systemd" : "none";
|
|
8957
|
+
}
|
|
8958
|
+
}
|
|
8959
|
+
return {
|
|
8960
|
+
registered,
|
|
8961
|
+
schedule_minutes: minutes,
|
|
8962
|
+
cron_expression: registered ? minutesToCron(minutes) : null,
|
|
8963
|
+
mechanism
|
|
8964
|
+
};
|
|
8965
|
+
}
|
|
8966
|
+
async function applyPgMigrations(connectionString, migrations, service = "unknown") {
|
|
8967
|
+
const pg2 = new PgAdapterAsync(connectionString);
|
|
8968
|
+
const result = {
|
|
8969
|
+
service,
|
|
8970
|
+
applied: [],
|
|
8971
|
+
alreadyApplied: [],
|
|
8972
|
+
errors: [],
|
|
8973
|
+
totalMigrations: migrations.length
|
|
8974
|
+
};
|
|
8975
|
+
try {
|
|
8976
|
+
await pg2.run(`CREATE TABLE IF NOT EXISTS _pg_migrations (
|
|
8977
|
+
id SERIAL PRIMARY KEY,
|
|
8978
|
+
version INT UNIQUE NOT NULL,
|
|
8979
|
+
applied_at TIMESTAMPTZ DEFAULT NOW()
|
|
8980
|
+
)`);
|
|
8981
|
+
const applied = await pg2.all("SELECT version FROM _pg_migrations ORDER BY version");
|
|
8982
|
+
const appliedSet = new Set(applied.map((r) => r.version));
|
|
8983
|
+
for (let i = 0;i < migrations.length; i++) {
|
|
8984
|
+
if (appliedSet.has(i)) {
|
|
8985
|
+
result.alreadyApplied.push(i);
|
|
8986
|
+
continue;
|
|
8987
|
+
}
|
|
8988
|
+
try {
|
|
8989
|
+
await pg2.exec(migrations[i]);
|
|
8990
|
+
await pg2.run("INSERT INTO _pg_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", i);
|
|
8991
|
+
result.applied.push(i);
|
|
8992
|
+
} catch (err) {
|
|
8993
|
+
result.errors.push(`Migration ${i}: ${err?.message ?? String(err)}`);
|
|
8994
|
+
break;
|
|
8995
|
+
}
|
|
8996
|
+
}
|
|
8997
|
+
} finally {
|
|
8998
|
+
await pg2.close();
|
|
8999
|
+
}
|
|
9000
|
+
return result;
|
|
9001
|
+
}
|
|
9002
|
+
function getServicePackage(service) {
|
|
9003
|
+
return `@hasna/${service}`;
|
|
9004
|
+
}
|
|
9005
|
+
async function loadServiceMigrations(service) {
|
|
9006
|
+
const pkg = getServicePackage(service);
|
|
9007
|
+
const paths = [
|
|
9008
|
+
`${pkg}/pg-migrations`,
|
|
9009
|
+
`${pkg}/dist/db/pg-migrations.js`,
|
|
9010
|
+
`${pkg}/dist/db/pg-migrations`
|
|
9011
|
+
];
|
|
9012
|
+
for (const path of paths) {
|
|
9013
|
+
try {
|
|
9014
|
+
const mod = await import(path);
|
|
9015
|
+
if (Array.isArray(mod.PG_MIGRATIONS)) {
|
|
9016
|
+
return mod.PG_MIGRATIONS;
|
|
9017
|
+
}
|
|
9018
|
+
if (mod.default && Array.isArray(mod.default.PG_MIGRATIONS)) {
|
|
9019
|
+
return mod.default.PG_MIGRATIONS;
|
|
9020
|
+
}
|
|
9021
|
+
} catch {}
|
|
9022
|
+
}
|
|
9023
|
+
return null;
|
|
9024
|
+
}
|
|
9025
|
+
async function migrateService(service, connectionString) {
|
|
9026
|
+
const connStr = connectionString ?? getConnectionString(service);
|
|
9027
|
+
const migrations = await loadServiceMigrations(service);
|
|
9028
|
+
if (!migrations) {
|
|
9029
|
+
return {
|
|
9030
|
+
service,
|
|
9031
|
+
applied: [],
|
|
9032
|
+
alreadyApplied: [],
|
|
9033
|
+
errors: [`No PG migrations found for service "${service}"`],
|
|
9034
|
+
totalMigrations: 0
|
|
9035
|
+
};
|
|
9036
|
+
}
|
|
9037
|
+
return applyPgMigrations(connStr, migrations, service);
|
|
9038
|
+
}
|
|
9039
|
+
async function migrateAllServices() {
|
|
9040
|
+
const { discoverServices: discoverServices2 } = await Promise.resolve().then(() => (init_discover(), exports_discover));
|
|
9041
|
+
const services = discoverServices2();
|
|
9042
|
+
const results = [];
|
|
9043
|
+
for (const service of services) {
|
|
9044
|
+
try {
|
|
9045
|
+
const result = await migrateService(service);
|
|
9046
|
+
results.push(result);
|
|
9047
|
+
} catch (err) {
|
|
9048
|
+
results.push({
|
|
9049
|
+
service,
|
|
9050
|
+
applied: [],
|
|
9051
|
+
alreadyApplied: [],
|
|
9052
|
+
errors: [err?.message ?? String(err)],
|
|
9053
|
+
totalMigrations: 0
|
|
9054
|
+
});
|
|
9055
|
+
}
|
|
9056
|
+
}
|
|
9057
|
+
return results;
|
|
9058
|
+
}
|
|
9059
|
+
async function ensurePgDatabase(service) {
|
|
9060
|
+
const config2 = (await Promise.resolve().then(() => (init_config(), exports_config))).getCloudConfig();
|
|
9061
|
+
const { host, port, username, password_env, ssl } = config2.rds;
|
|
9062
|
+
if (!host || !username)
|
|
9063
|
+
return false;
|
|
9064
|
+
const password = process.env[password_env] ?? "";
|
|
9065
|
+
const sslParam = ssl ? "?sslmode=require" : "";
|
|
9066
|
+
const adminConnStr = `postgres://${username}:${encodeURIComponent(password)}@${host}:${port}/postgres${sslParam}`;
|
|
9067
|
+
const pg2 = new PgAdapterAsync(adminConnStr);
|
|
9068
|
+
try {
|
|
9069
|
+
const existing = await pg2.all(`SELECT 1 FROM pg_database WHERE datname = $1`, service);
|
|
9070
|
+
if (existing.length === 0) {
|
|
9071
|
+
await pg2.exec(`CREATE DATABASE "${service}"`);
|
|
9072
|
+
return true;
|
|
9073
|
+
}
|
|
9074
|
+
return false;
|
|
9075
|
+
} finally {
|
|
9076
|
+
await pg2.close();
|
|
9077
|
+
}
|
|
9078
|
+
}
|
|
9079
|
+
async function ensureAllPgDatabases() {
|
|
9080
|
+
const { discoverServices: discoverServices2 } = await Promise.resolve().then(() => (init_discover(), exports_discover));
|
|
9081
|
+
const services = discoverServices2();
|
|
9082
|
+
const results = [];
|
|
9083
|
+
for (const service of services) {
|
|
9084
|
+
try {
|
|
9085
|
+
const created = await ensurePgDatabase(service);
|
|
9086
|
+
results.push({ service, created });
|
|
9087
|
+
} catch (err) {
|
|
9088
|
+
results.push({ service, created: false, error: err?.message ?? String(err) });
|
|
9089
|
+
}
|
|
9090
|
+
}
|
|
9091
|
+
return results;
|
|
9092
|
+
}
|
|
8131
9093
|
function registerCloudTools(server, serviceName) {
|
|
8132
9094
|
server.tool(`${serviceName}_cloud_status`, "Show cloud configuration and connection health", {}, async () => {
|
|
8133
9095
|
const config2 = getCloudConfig();
|
|
@@ -8228,6 +9190,85 @@ function registerCloudTools(server, serviceName) {
|
|
|
8228
9190
|
};
|
|
8229
9191
|
});
|
|
8230
9192
|
}
|
|
9193
|
+
function registerCloudCommands(program, serviceName) {
|
|
9194
|
+
const cloudCmd = program.command("cloud").description("Cloud sync and feedback commands");
|
|
9195
|
+
cloudCmd.command("status").description("Show cloud config and connection health").action(async () => {
|
|
9196
|
+
const config2 = getCloudConfig();
|
|
9197
|
+
console.log("Mode:", config2.mode);
|
|
9198
|
+
console.log("RDS Host:", config2.rds.host || "(not configured)");
|
|
9199
|
+
console.log("Service:", serviceName);
|
|
9200
|
+
if (config2.rds.host && config2.rds.username) {
|
|
9201
|
+
try {
|
|
9202
|
+
const connStr = getConnectionString("postgres");
|
|
9203
|
+
const pg2 = new PgAdapterAsync(connStr);
|
|
9204
|
+
await pg2.get("SELECT 1 as ok");
|
|
9205
|
+
console.log("PostgreSQL: connected");
|
|
9206
|
+
await pg2.close();
|
|
9207
|
+
} catch (err) {
|
|
9208
|
+
console.log("PostgreSQL: connection failed \u2014", err?.message);
|
|
9209
|
+
}
|
|
9210
|
+
}
|
|
9211
|
+
});
|
|
9212
|
+
cloudCmd.command("push").description("Push local data to cloud").option("--tables <tables>", "Comma-separated table names").action(async (opts) => {
|
|
9213
|
+
const config2 = getCloudConfig();
|
|
9214
|
+
if (config2.mode === "local") {
|
|
9215
|
+
console.error("Error: mode is 'local'. Run `cloud setup` first.");
|
|
9216
|
+
process.exit(1);
|
|
9217
|
+
}
|
|
9218
|
+
const local = new SqliteAdapter(getDbPath(serviceName));
|
|
9219
|
+
const cloud = new PgAdapterAsync(getConnectionString(serviceName));
|
|
9220
|
+
const tables = opts.tables ? opts.tables.split(",").map((t) => t.trim()) : listSqliteTables(local);
|
|
9221
|
+
const results = await syncPush(local, cloud, {
|
|
9222
|
+
tables,
|
|
9223
|
+
onProgress: (p) => {
|
|
9224
|
+
if (p.phase === "done") {
|
|
9225
|
+
console.log(` ${p.table}: ${p.rowsWritten} rows pushed`);
|
|
9226
|
+
}
|
|
9227
|
+
}
|
|
9228
|
+
});
|
|
9229
|
+
local.close();
|
|
9230
|
+
await cloud.close();
|
|
9231
|
+
const total = results.reduce((s, r) => s + r.rowsWritten, 0);
|
|
9232
|
+
console.log(`Done. ${total} rows pushed.`);
|
|
9233
|
+
});
|
|
9234
|
+
cloudCmd.command("pull").description("Pull cloud data to local").option("--tables <tables>", "Comma-separated table names").action(async (opts) => {
|
|
9235
|
+
const config2 = getCloudConfig();
|
|
9236
|
+
if (config2.mode === "local") {
|
|
9237
|
+
console.error("Error: mode is 'local'. Run `cloud setup` first.");
|
|
9238
|
+
process.exit(1);
|
|
9239
|
+
}
|
|
9240
|
+
const local = new SqliteAdapter(getDbPath(serviceName));
|
|
9241
|
+
const cloud = new PgAdapterAsync(getConnectionString(serviceName));
|
|
9242
|
+
let tables;
|
|
9243
|
+
if (opts.tables) {
|
|
9244
|
+
tables = opts.tables.split(",").map((t) => t.trim());
|
|
9245
|
+
} else {
|
|
9246
|
+
tables = await listPgTables(cloud);
|
|
9247
|
+
}
|
|
9248
|
+
const results = await syncPull(cloud, local, {
|
|
9249
|
+
tables,
|
|
9250
|
+
onProgress: (p) => {
|
|
9251
|
+
if (p.phase === "done") {
|
|
9252
|
+
console.log(` ${p.table}: ${p.rowsWritten} rows pulled`);
|
|
9253
|
+
}
|
|
9254
|
+
}
|
|
9255
|
+
});
|
|
9256
|
+
local.close();
|
|
9257
|
+
await cloud.close();
|
|
9258
|
+
const total = results.reduce((s, r) => s + r.rowsWritten, 0);
|
|
9259
|
+
console.log(`Done. ${total} rows pulled.`);
|
|
9260
|
+
});
|
|
9261
|
+
cloudCmd.command("feedback").description("Send feedback").requiredOption("--message <msg>", "Feedback message").option("--email <email>", "Contact email").action(async (opts) => {
|
|
9262
|
+
const db = createDatabase({ service: "cloud" });
|
|
9263
|
+
const result = await sendFeedback({ service: serviceName, message: opts.message, email: opts.email }, db);
|
|
9264
|
+
db.close();
|
|
9265
|
+
if (result.sent) {
|
|
9266
|
+
console.log(`Feedback sent (id: ${result.id})`);
|
|
9267
|
+
} else {
|
|
9268
|
+
console.log(`Feedback saved locally (id: ${result.id}): ${result.error}`);
|
|
9269
|
+
}
|
|
9270
|
+
});
|
|
9271
|
+
}
|
|
8231
9272
|
var __create2, __getProtoOf2, __defProp2, __getOwnPropNames2, __hasOwnProp2, __toESMCache_node2, __toESMCache_esm2, __toESM2 = (mod, isNodeMode, target) => {
|
|
8232
9273
|
var canCache = mod != null && typeof mod === "object";
|
|
8233
9274
|
if (canCache) {
|
|
@@ -8480,7 +9521,13 @@ CREATE TABLE IF NOT EXISTS feedback (
|
|
|
8480
9521
|
email TEXT DEFAULT '',
|
|
8481
9522
|
machine_id TEXT DEFAULT '',
|
|
8482
9523
|
created_at TEXT DEFAULT (datetime('now'))
|
|
8483
|
-
)`,
|
|
9524
|
+
)`, SYNC_META_TABLE_SQL = `
|
|
9525
|
+
CREATE TABLE IF NOT EXISTS _sync_meta (
|
|
9526
|
+
table_name TEXT PRIMARY KEY,
|
|
9527
|
+
last_synced_at TEXT,
|
|
9528
|
+
last_synced_row_count INTEGER DEFAULT 0,
|
|
9529
|
+
direction TEXT DEFAULT 'push'
|
|
9530
|
+
)`, AUTO_SYNC_CONFIG_PATH, DEFAULT_AUTO_SYNC_CONFIG, cleanupHandlers, signalHandlersInstalled = false, SERVICE_NAME = "hasna-cloud-sync", CONFIG_DIR2;
|
|
8484
9531
|
var init_dist = __esm(() => {
|
|
8485
9532
|
__create2 = Object.create;
|
|
8486
9533
|
__getProtoOf2 = Object.getPrototypeOf;
|
|
@@ -16548,6 +17595,11 @@ See https://www.postgresql.org/docs/current/libpq-ssl.html for libpq SSL mode de
|
|
|
16548
17595
|
init_config();
|
|
16549
17596
|
init_discover();
|
|
16550
17597
|
AUTO_SYNC_CONFIG_PATH = join4(homedir4(), ".hasna", "cloud", "config.json");
|
|
17598
|
+
DEFAULT_AUTO_SYNC_CONFIG = {
|
|
17599
|
+
auto_sync_on_start: true,
|
|
17600
|
+
auto_sync_on_stop: true
|
|
17601
|
+
};
|
|
17602
|
+
cleanupHandlers = [];
|
|
16551
17603
|
init_config();
|
|
16552
17604
|
init_adapter();
|
|
16553
17605
|
init_dotfile();
|
|
@@ -16573,23 +17625,23 @@ __export(exports_db, {
|
|
|
16573
17625
|
getDataDir: () => getDataDir2,
|
|
16574
17626
|
closeDb: () => closeDb
|
|
16575
17627
|
});
|
|
16576
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
16577
|
-
import { join as
|
|
17628
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync7, mkdirSync as mkdirSync4, readdirSync as readdirSync4, statSync } from "fs";
|
|
17629
|
+
import { join as join7, dirname as dirname2 } from "path";
|
|
16578
17630
|
import { homedir as homedir6 } from "os";
|
|
16579
17631
|
function getDataDir2() {
|
|
16580
17632
|
const home = process.env["HOME"] || process.env["USERPROFILE"] || homedir6();
|
|
16581
|
-
const newDir =
|
|
16582
|
-
const oldDir =
|
|
16583
|
-
if (
|
|
16584
|
-
|
|
16585
|
-
for (const file2 of
|
|
16586
|
-
const oldPath =
|
|
17633
|
+
const newDir = join7(home, ".hasna", "conversations");
|
|
17634
|
+
const oldDir = join7(home, ".conversations");
|
|
17635
|
+
if (existsSync7(oldDir) && !existsSync7(newDir)) {
|
|
17636
|
+
mkdirSync4(newDir, { recursive: true });
|
|
17637
|
+
for (const file2 of readdirSync4(oldDir)) {
|
|
17638
|
+
const oldPath = join7(oldDir, file2);
|
|
16587
17639
|
if (statSync(oldPath).isFile()) {
|
|
16588
|
-
copyFileSync2(oldPath,
|
|
17640
|
+
copyFileSync2(oldPath, join7(newDir, file2));
|
|
16589
17641
|
}
|
|
16590
17642
|
}
|
|
16591
17643
|
}
|
|
16592
|
-
|
|
17644
|
+
mkdirSync4(newDir, { recursive: true });
|
|
16593
17645
|
return newDir;
|
|
16594
17646
|
}
|
|
16595
17647
|
function getDbPath2() {
|
|
@@ -16597,19 +17649,20 @@ function getDbPath2() {
|
|
|
16597
17649
|
return process.env.HASNA_CONVERSATIONS_DB_PATH;
|
|
16598
17650
|
if (process.env.CONVERSATIONS_DB_PATH)
|
|
16599
17651
|
return process.env.CONVERSATIONS_DB_PATH;
|
|
16600
|
-
return
|
|
17652
|
+
return join7(getDataDir2(), "messages.db");
|
|
16601
17653
|
}
|
|
16602
17654
|
function getDb() {
|
|
16603
17655
|
if (db)
|
|
16604
17656
|
return db;
|
|
16605
17657
|
const dbPath = getDbPath2();
|
|
16606
|
-
|
|
17658
|
+
mkdirSync4(dirname2(dbPath), { recursive: true });
|
|
16607
17659
|
db = new SqliteAdapter(dbPath);
|
|
16608
17660
|
db.exec("PRAGMA journal_mode = WAL");
|
|
16609
17661
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
16610
17662
|
db.exec(`
|
|
16611
17663
|
CREATE TABLE IF NOT EXISTS messages (
|
|
16612
17664
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17665
|
+
uuid TEXT NOT NULL DEFAULT (lower(hex(randomblob(16)))),
|
|
16613
17666
|
session_id TEXT NOT NULL,
|
|
16614
17667
|
from_agent TEXT NOT NULL,
|
|
16615
17668
|
to_agent TEXT NOT NULL,
|
|
@@ -16629,6 +17682,7 @@ function getDb() {
|
|
|
16629
17682
|
db.exec("CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent)");
|
|
16630
17683
|
db.exec("CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at)");
|
|
16631
17684
|
db.exec("CREATE INDEX IF NOT EXISTS idx_messages_space ON messages(space)");
|
|
17685
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
|
|
16632
17686
|
db.exec(`
|
|
16633
17687
|
CREATE TABLE IF NOT EXISTS projects (
|
|
16634
17688
|
id TEXT PRIMARY KEY,
|
|
@@ -16776,6 +17830,11 @@ function getDb() {
|
|
|
16776
17830
|
db.exec("ALTER TABLE messages ADD COLUMN project_id TEXT");
|
|
16777
17831
|
db.exec("CREATE INDEX IF NOT EXISTS idx_messages_project ON messages(project_id)");
|
|
16778
17832
|
}
|
|
17833
|
+
if (!colNames2.includes("uuid")) {
|
|
17834
|
+
db.exec("ALTER TABLE messages ADD COLUMN uuid TEXT");
|
|
17835
|
+
db.exec("UPDATE messages SET uuid = lower(hex(randomblob(16))) WHERE uuid IS NULL");
|
|
17836
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
|
|
17837
|
+
}
|
|
16779
17838
|
const presenceCols = db.prepare("PRAGMA table_info(agent_presence)").all();
|
|
16780
17839
|
const presenceColNames = presenceCols.map((c) => c.name);
|
|
16781
17840
|
if (!presenceColNames.includes("id")) {
|
|
@@ -16880,6 +17939,187 @@ var init_db = __esm(() => {
|
|
|
16880
17939
|
init_dist();
|
|
16881
17940
|
});
|
|
16882
17941
|
|
|
17942
|
+
// src/lib/pg-migrations.ts
|
|
17943
|
+
var exports_pg_migrations = {};
|
|
17944
|
+
__export(exports_pg_migrations, {
|
|
17945
|
+
PG_MIGRATIONS: () => PG_MIGRATIONS
|
|
17946
|
+
});
|
|
17947
|
+
var PG_MIGRATIONS;
|
|
17948
|
+
var init_pg_migrations = __esm(() => {
|
|
17949
|
+
PG_MIGRATIONS = [
|
|
17950
|
+
`
|
|
17951
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
17952
|
+
id TEXT PRIMARY KEY,
|
|
17953
|
+
name TEXT NOT NULL UNIQUE,
|
|
17954
|
+
description TEXT,
|
|
17955
|
+
path TEXT,
|
|
17956
|
+
created_by TEXT NOT NULL,
|
|
17957
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
17958
|
+
metadata TEXT,
|
|
17959
|
+
tags TEXT,
|
|
17960
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
17961
|
+
repository TEXT,
|
|
17962
|
+
settings TEXT
|
|
17963
|
+
);
|
|
17964
|
+
CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name);
|
|
17965
|
+
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
|
17966
|
+
|
|
17967
|
+
CREATE TABLE IF NOT EXISTS spaces (
|
|
17968
|
+
name TEXT PRIMARY KEY,
|
|
17969
|
+
description TEXT,
|
|
17970
|
+
parent_id TEXT REFERENCES spaces(name),
|
|
17971
|
+
project_id TEXT REFERENCES projects(id),
|
|
17972
|
+
created_by TEXT NOT NULL,
|
|
17973
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
17974
|
+
archived_at TEXT,
|
|
17975
|
+
topic TEXT
|
|
17976
|
+
);
|
|
17977
|
+
CREATE INDEX IF NOT EXISTS idx_spaces_parent ON spaces(parent_id);
|
|
17978
|
+
CREATE INDEX IF NOT EXISTS idx_spaces_project ON spaces(project_id);
|
|
17979
|
+
|
|
17980
|
+
CREATE TABLE IF NOT EXISTS space_members (
|
|
17981
|
+
space TEXT NOT NULL REFERENCES spaces(name),
|
|
17982
|
+
agent TEXT NOT NULL,
|
|
17983
|
+
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
17984
|
+
PRIMARY KEY (space, agent)
|
|
17985
|
+
);
|
|
17986
|
+
|
|
17987
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
17988
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
17989
|
+
uuid TEXT NOT NULL DEFAULT gen_random_uuid()::text UNIQUE,
|
|
17990
|
+
session_id TEXT NOT NULL,
|
|
17991
|
+
from_agent TEXT NOT NULL,
|
|
17992
|
+
to_agent TEXT NOT NULL,
|
|
17993
|
+
space TEXT,
|
|
17994
|
+
project_id TEXT,
|
|
17995
|
+
content TEXT NOT NULL,
|
|
17996
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
17997
|
+
working_dir TEXT,
|
|
17998
|
+
repository TEXT,
|
|
17999
|
+
branch TEXT,
|
|
18000
|
+
metadata TEXT,
|
|
18001
|
+
edited_at TEXT,
|
|
18002
|
+
pinned_at TEXT,
|
|
18003
|
+
blocking BOOLEAN NOT NULL DEFAULT FALSE,
|
|
18004
|
+
attachments TEXT,
|
|
18005
|
+
reply_to BIGINT,
|
|
18006
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18007
|
+
read_at TEXT
|
|
18008
|
+
);
|
|
18009
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
|
18010
|
+
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent);
|
|
18011
|
+
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
|
18012
|
+
CREATE INDEX IF NOT EXISTS idx_messages_space ON messages(space);
|
|
18013
|
+
CREATE INDEX IF NOT EXISTS idx_messages_pinned ON messages(pinned_at);
|
|
18014
|
+
CREATE INDEX IF NOT EXISTS idx_messages_blocking ON messages(blocking);
|
|
18015
|
+
CREATE INDEX IF NOT EXISTS idx_messages_reply_to ON messages(reply_to);
|
|
18016
|
+
CREATE INDEX IF NOT EXISTS idx_messages_project ON messages(project_id);
|
|
18017
|
+
|
|
18018
|
+
CREATE TABLE IF NOT EXISTS agent_presence (
|
|
18019
|
+
id TEXT NOT NULL DEFAULT '',
|
|
18020
|
+
agent TEXT PRIMARY KEY,
|
|
18021
|
+
session_id TEXT,
|
|
18022
|
+
role TEXT NOT NULL DEFAULT 'agent',
|
|
18023
|
+
project_id TEXT,
|
|
18024
|
+
status TEXT NOT NULL DEFAULT 'online',
|
|
18025
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18026
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18027
|
+
metadata TEXT
|
|
18028
|
+
);
|
|
18029
|
+
|
|
18030
|
+
CREATE TABLE IF NOT EXISTS resource_locks (
|
|
18031
|
+
resource_type TEXT NOT NULL,
|
|
18032
|
+
resource_id TEXT NOT NULL,
|
|
18033
|
+
agent_id TEXT NOT NULL,
|
|
18034
|
+
lock_type TEXT NOT NULL DEFAULT 'advisory',
|
|
18035
|
+
locked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18036
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
18037
|
+
UNIQUE(resource_type, resource_id, lock_type)
|
|
18038
|
+
);
|
|
18039
|
+
CREATE INDEX IF NOT EXISTS idx_locks_resource ON resource_locks(resource_type, resource_id);
|
|
18040
|
+
CREATE INDEX IF NOT EXISTS idx_locks_agent ON resource_locks(agent_id);
|
|
18041
|
+
CREATE INDEX IF NOT EXISTS idx_locks_expires ON resource_locks(expires_at);
|
|
18042
|
+
|
|
18043
|
+
CREATE TABLE IF NOT EXISTS reactions (
|
|
18044
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
18045
|
+
message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
18046
|
+
agent TEXT NOT NULL,
|
|
18047
|
+
emoji TEXT NOT NULL,
|
|
18048
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18049
|
+
UNIQUE(message_id, agent, emoji)
|
|
18050
|
+
);
|
|
18051
|
+
CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id);
|
|
18052
|
+
|
|
18053
|
+
CREATE TABLE IF NOT EXISTS message_read_receipts (
|
|
18054
|
+
message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
18055
|
+
agent TEXT NOT NULL,
|
|
18056
|
+
read_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18057
|
+
PRIMARY KEY (message_id, agent)
|
|
18058
|
+
);
|
|
18059
|
+
CREATE INDEX IF NOT EXISTS idx_read_receipts_message ON message_read_receipts(message_id);
|
|
18060
|
+
CREATE INDEX IF NOT EXISTS idx_read_receipts_agent ON message_read_receipts(agent);
|
|
18061
|
+
|
|
18062
|
+
CREATE TABLE IF NOT EXISTS message_mentions (
|
|
18063
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
18064
|
+
message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
18065
|
+
mentioned_agent TEXT NOT NULL,
|
|
18066
|
+
from_agent TEXT NOT NULL,
|
|
18067
|
+
space TEXT,
|
|
18068
|
+
notified_at TEXT,
|
|
18069
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
18070
|
+
);
|
|
18071
|
+
CREATE INDEX IF NOT EXISTS idx_mentions_agent ON message_mentions(mentioned_agent);
|
|
18072
|
+
CREATE INDEX IF NOT EXISTS idx_mentions_message ON message_mentions(message_id);
|
|
18073
|
+
CREATE INDEX IF NOT EXISTS idx_mentions_notified ON message_mentions(notified_at);
|
|
18074
|
+
|
|
18075
|
+
-- Full-text search using PostgreSQL tsvector
|
|
18076
|
+
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
|
18077
|
+
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
|
18078
|
+
|
|
18079
|
+
CREATE OR REPLACE FUNCTION messages_search_vector_update() RETURNS trigger AS $$
|
|
18080
|
+
BEGIN
|
|
18081
|
+
NEW.search_vector :=
|
|
18082
|
+
setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'A') ||
|
|
18083
|
+
setweight(to_tsvector('english', COALESCE(NEW.from_agent, '')), 'B') ||
|
|
18084
|
+
setweight(to_tsvector('english', COALESCE(NEW.to_agent, '')), 'B') ||
|
|
18085
|
+
setweight(to_tsvector('english', COALESCE(NEW.space, '')), 'C');
|
|
18086
|
+
RETURN NEW;
|
|
18087
|
+
END;
|
|
18088
|
+
$$ LANGUAGE plpgsql;
|
|
18089
|
+
|
|
18090
|
+
DROP TRIGGER IF EXISTS messages_search_vector_trigger ON messages;
|
|
18091
|
+
CREATE TRIGGER messages_search_vector_trigger
|
|
18092
|
+
BEFORE INSERT OR UPDATE OF content, from_agent, to_agent, space ON messages
|
|
18093
|
+
FOR EACH ROW EXECUTE FUNCTION messages_search_vector_update();
|
|
18094
|
+
|
|
18095
|
+
-- Backfill existing rows
|
|
18096
|
+
UPDATE messages SET search_vector =
|
|
18097
|
+
setweight(to_tsvector('english', COALESCE(content, '')), 'A') ||
|
|
18098
|
+
setweight(to_tsvector('english', COALESCE(from_agent, '')), 'B') ||
|
|
18099
|
+
setweight(to_tsvector('english', COALESCE(to_agent, '')), 'B') ||
|
|
18100
|
+
setweight(to_tsvector('english', COALESCE(space, '')), 'C')
|
|
18101
|
+
WHERE search_vector IS NULL;
|
|
18102
|
+
|
|
18103
|
+
-- Feedback table
|
|
18104
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
18105
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
18106
|
+
message TEXT NOT NULL,
|
|
18107
|
+
email TEXT,
|
|
18108
|
+
category TEXT DEFAULT 'general',
|
|
18109
|
+
version TEXT,
|
|
18110
|
+
machine_id TEXT,
|
|
18111
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
18112
|
+
);
|
|
18113
|
+
|
|
18114
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
18115
|
+
id INTEGER PRIMARY KEY,
|
|
18116
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
18117
|
+
);
|
|
18118
|
+
INSERT INTO _migrations (id) VALUES (1) ON CONFLICT DO NOTHING;
|
|
18119
|
+
`
|
|
18120
|
+
];
|
|
18121
|
+
});
|
|
18122
|
+
|
|
16883
18123
|
// node_modules/zod/v3/helpers/util.js
|
|
16884
18124
|
var util;
|
|
16885
18125
|
(function(util2) {
|
|
@@ -38697,9 +39937,6 @@ class StdioServerTransport {
|
|
|
38697
39937
|
}
|
|
38698
39938
|
}
|
|
38699
39939
|
|
|
38700
|
-
// src/mcp/index.ts
|
|
38701
|
-
init_dist();
|
|
38702
|
-
|
|
38703
39940
|
// src/lib/presence.ts
|
|
38704
39941
|
init_db();
|
|
38705
39942
|
var ONLINE_THRESHOLD_SECONDS = 60;
|
|
@@ -38832,25 +40069,25 @@ function renameAgent(oldName, newName) {
|
|
|
38832
40069
|
// src/lib/messages.ts
|
|
38833
40070
|
init_db();
|
|
38834
40071
|
import { randomUUID } from "crypto";
|
|
38835
|
-
import { mkdirSync as
|
|
38836
|
-
import { join as
|
|
40072
|
+
import { mkdirSync as mkdirSync6, copyFileSync as copyFileSync3, statSync as statSync2 } from "fs";
|
|
40073
|
+
import { join as join10 } from "path";
|
|
38837
40074
|
|
|
38838
40075
|
// src/lib/webhooks.ts
|
|
38839
40076
|
init_db();
|
|
38840
|
-
import { readFileSync as
|
|
38841
|
-
import { join as
|
|
40077
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
40078
|
+
import { join as join9 } from "path";
|
|
38842
40079
|
var cachedConfig = null;
|
|
38843
40080
|
var configLoadedAt = 0;
|
|
38844
40081
|
var CONFIG_CACHE_MS = 1e4;
|
|
38845
40082
|
function getConfigPath2() {
|
|
38846
|
-
return process.env.CONVERSATIONS_CONFIG_PATH ||
|
|
40083
|
+
return process.env.CONVERSATIONS_CONFIG_PATH || join9(getDataDir2(), "config.json");
|
|
38847
40084
|
}
|
|
38848
40085
|
function loadConfig() {
|
|
38849
40086
|
const now = Date.now();
|
|
38850
40087
|
if (cachedConfig && now - configLoadedAt < CONFIG_CACHE_MS)
|
|
38851
40088
|
return cachedConfig;
|
|
38852
40089
|
try {
|
|
38853
|
-
const raw =
|
|
40090
|
+
const raw = readFileSync3(getConfigPath2(), "utf-8");
|
|
38854
40091
|
cachedConfig = JSON.parse(raw);
|
|
38855
40092
|
configLoadedAt = now;
|
|
38856
40093
|
return cachedConfig;
|
|
@@ -38938,7 +40175,7 @@ function parseMessage(row) {
|
|
|
38938
40175
|
function getAttachmentsDir() {
|
|
38939
40176
|
if (process.env.CONVERSATIONS_ATTACHMENTS_DIR)
|
|
38940
40177
|
return process.env.CONVERSATIONS_ATTACHMENTS_DIR;
|
|
38941
|
-
return
|
|
40178
|
+
return join10(getDataDir2(), "attachments");
|
|
38942
40179
|
}
|
|
38943
40180
|
function guessMimeType(name) {
|
|
38944
40181
|
const ext = name.split(".").pop()?.toLowerCase();
|
|
@@ -38967,7 +40204,30 @@ function guessMimeType(name) {
|
|
|
38967
40204
|
};
|
|
38968
40205
|
return mimeMap[ext || ""] || "application/octet-stream";
|
|
38969
40206
|
}
|
|
40207
|
+
var MAX_MESSAGE_BYTES = 65536;
|
|
40208
|
+
var RATE_LIMIT_MAX = 60;
|
|
40209
|
+
var RATE_LIMIT_WINDOW_MS = 60000;
|
|
40210
|
+
var _rateLimitCounters = new Map;
|
|
40211
|
+
function checkRateLimit(agentId) {
|
|
40212
|
+
const dbPath = process.env.CONVERSATIONS_DB_PATH ?? process.env.HASNA_CONVERSATIONS_DB_PATH ?? "";
|
|
40213
|
+
if (dbPath === ":memory:" || dbPath.includes("test") || dbPath.includes("tmp"))
|
|
40214
|
+
return;
|
|
40215
|
+
const now = Date.now();
|
|
40216
|
+
const entry = _rateLimitCounters.get(agentId);
|
|
40217
|
+
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
|
|
40218
|
+
_rateLimitCounters.set(agentId, { count: 1, windowStart: now });
|
|
40219
|
+
return;
|
|
40220
|
+
}
|
|
40221
|
+
entry.count++;
|
|
40222
|
+
if (entry.count > RATE_LIMIT_MAX) {
|
|
40223
|
+
throw new Error(`Rate limit exceeded: ${agentId} may send at most ${RATE_LIMIT_MAX} messages per minute.`);
|
|
40224
|
+
}
|
|
40225
|
+
}
|
|
38970
40226
|
function sendMessage(opts) {
|
|
40227
|
+
if (Buffer.byteLength(opts.content, "utf8") > MAX_MESSAGE_BYTES) {
|
|
40228
|
+
throw new Error(`Message content exceeds maximum size of ${MAX_MESSAGE_BYTES} bytes (64 KB).`);
|
|
40229
|
+
}
|
|
40230
|
+
checkRateLimit(opts.from);
|
|
38971
40231
|
const db2 = getDb();
|
|
38972
40232
|
const explicitSession = opts.session_id && opts.session_id.trim().length > 0 ? opts.session_id : undefined;
|
|
38973
40233
|
const sessionId = explicitSession ?? (opts.space ? `space:${opts.space}` : `${[opts.from, opts.to].sort().join("-")}-${randomUUID().slice(0, 8)}`);
|
|
@@ -38983,11 +40243,11 @@ function sendMessage(opts) {
|
|
|
38983
40243
|
const row = stmt.get(sessionId, opts.from, opts.to, opts.space || null, opts.project_id || null, opts.content, normalizedPriority, opts.working_dir || null, opts.repository || null, opts.branch || null, metadata, blocking, replyTo);
|
|
38984
40244
|
const message = parseMessage(row);
|
|
38985
40245
|
if (opts.attachments && opts.attachments.length > 0) {
|
|
38986
|
-
const attachmentsDir =
|
|
38987
|
-
|
|
40246
|
+
const attachmentsDir = join10(getAttachmentsDir(), String(message.id));
|
|
40247
|
+
mkdirSync6(attachmentsDir, { recursive: true });
|
|
38988
40248
|
const attachmentInfos = [];
|
|
38989
40249
|
for (const att of opts.attachments) {
|
|
38990
|
-
const destPath =
|
|
40250
|
+
const destPath = join10(attachmentsDir, att.name);
|
|
38991
40251
|
copyFileSync3(att.source_path, destPath);
|
|
38992
40252
|
const stat = statSync2(destPath);
|
|
38993
40253
|
attachmentInfos.push({
|
|
@@ -39548,8 +40808,8 @@ function getSessionActivity(sessionId) {
|
|
|
39548
40808
|
}
|
|
39549
40809
|
|
|
39550
40810
|
// src/lib/identity.ts
|
|
39551
|
-
import { readFileSync as
|
|
39552
|
-
import { join as
|
|
40811
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync7 } from "fs";
|
|
40812
|
+
import { join as join11, dirname as dirname3 } from "path";
|
|
39553
40813
|
|
|
39554
40814
|
// src/lib/names.ts
|
|
39555
40815
|
var AGENT_NAMES = [
|
|
@@ -39902,7 +41162,7 @@ var AGENT_NAMES = [
|
|
|
39902
41162
|
|
|
39903
41163
|
// src/lib/identity.ts
|
|
39904
41164
|
init_db();
|
|
39905
|
-
var AGENT_ID_FILE =
|
|
41165
|
+
var AGENT_ID_FILE = join11(getDataDir2(), "agent-id");
|
|
39906
41166
|
var cachedAutoName = null;
|
|
39907
41167
|
function isNameTaken(name) {
|
|
39908
41168
|
try {
|
|
@@ -39918,7 +41178,7 @@ function getAutoName() {
|
|
|
39918
41178
|
if (cachedAutoName)
|
|
39919
41179
|
return cachedAutoName;
|
|
39920
41180
|
try {
|
|
39921
|
-
const name2 =
|
|
41181
|
+
const name2 = readFileSync5(AGENT_ID_FILE, "utf-8").trim();
|
|
39922
41182
|
if (name2) {
|
|
39923
41183
|
cachedAutoName = name2;
|
|
39924
41184
|
return name2;
|
|
@@ -39934,8 +41194,8 @@ function getAutoName() {
|
|
|
39934
41194
|
}
|
|
39935
41195
|
cachedAutoName = name;
|
|
39936
41196
|
try {
|
|
39937
|
-
|
|
39938
|
-
|
|
41197
|
+
mkdirSync7(dirname3(AGENT_ID_FILE), { recursive: true });
|
|
41198
|
+
writeFileSync3(AGENT_ID_FILE, name + `
|
|
39939
41199
|
`, "utf-8");
|
|
39940
41200
|
} catch {}
|
|
39941
41201
|
return name;
|
|
@@ -39952,8 +41212,8 @@ function resolveIdentity(explicit) {
|
|
|
39952
41212
|
function updateCachedAutoName(newName) {
|
|
39953
41213
|
cachedAutoName = newName;
|
|
39954
41214
|
try {
|
|
39955
|
-
|
|
39956
|
-
|
|
41215
|
+
mkdirSync7(dirname3(AGENT_ID_FILE), { recursive: true });
|
|
41216
|
+
writeFileSync3(AGENT_ID_FILE, newName + `
|
|
39957
41217
|
`, "utf-8");
|
|
39958
41218
|
} catch {}
|
|
39959
41219
|
}
|
|
@@ -42504,10 +43764,295 @@ function registerAdvancedTools(server, pkgVersion) {
|
|
|
42504
43764
|
}
|
|
42505
43765
|
});
|
|
42506
43766
|
}
|
|
43767
|
+
|
|
43768
|
+
// src/mcp/tools/cloud.ts
|
|
43769
|
+
var SYNC_EXCLUDED = new Set([
|
|
43770
|
+
"messages",
|
|
43771
|
+
"reactions",
|
|
43772
|
+
"message_read_receipts",
|
|
43773
|
+
"message_mentions",
|
|
43774
|
+
"messages_fts",
|
|
43775
|
+
"_sync_conflicts",
|
|
43776
|
+
"_migrations"
|
|
43777
|
+
]);
|
|
43778
|
+
var CONFLICT_TABLES = new Set(["spaces", "projects", "agent_presence"]);
|
|
43779
|
+
async function detectAndLogConflicts(local, cloud, table) {
|
|
43780
|
+
if (!CONFLICT_TABLES.has(table))
|
|
43781
|
+
return 0;
|
|
43782
|
+
try {
|
|
43783
|
+
const { detectConflicts: detectConflicts2, storeConflicts: storeConflicts2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
|
|
43784
|
+
const pk = table === "messages" ? "id" : table === "spaces" ? "name" : table === "space_members" ? "space" : "id";
|
|
43785
|
+
const tsCol = "created_at";
|
|
43786
|
+
const localRows = local.all(`SELECT * FROM "${table}"`);
|
|
43787
|
+
const remoteRows = await cloud.all(`SELECT * FROM "${table}"`);
|
|
43788
|
+
if (localRows.length === 0 || remoteRows.length === 0)
|
|
43789
|
+
return 0;
|
|
43790
|
+
const conflicts = detectConflicts2(localRows, remoteRows, table, pk, tsCol);
|
|
43791
|
+
if (conflicts.length > 0) {
|
|
43792
|
+
storeConflicts2(local, conflicts);
|
|
43793
|
+
}
|
|
43794
|
+
return conflicts.length;
|
|
43795
|
+
} catch {
|
|
43796
|
+
return 0;
|
|
43797
|
+
}
|
|
43798
|
+
}
|
|
43799
|
+
function registerCloudSyncTools(server) {
|
|
43800
|
+
server.tool("conversations_cloud_status", "Show cloud configuration, connection health, and sync status", {}, async () => {
|
|
43801
|
+
try {
|
|
43802
|
+
const {
|
|
43803
|
+
getCloudConfig: getCloudConfig2,
|
|
43804
|
+
getConnectionString: getConnectionString2,
|
|
43805
|
+
PgAdapterAsync: PgAdapterAsync2,
|
|
43806
|
+
listConflicts: listConflicts2,
|
|
43807
|
+
ensureConflictsTable: ensureConflictsTable2,
|
|
43808
|
+
SqliteAdapter: SqliteAdapter2,
|
|
43809
|
+
getDbPath: cloudGetDbPath
|
|
43810
|
+
} = await Promise.resolve().then(() => (init_dist(), exports_dist));
|
|
43811
|
+
const config2 = getCloudConfig2();
|
|
43812
|
+
const lines = [
|
|
43813
|
+
`Mode: ${config2.mode}`,
|
|
43814
|
+
`Service: conversations`,
|
|
43815
|
+
`RDS Host: ${config2.rds.host || "(not configured)"}`
|
|
43816
|
+
];
|
|
43817
|
+
if (config2.rds.host && config2.rds.username) {
|
|
43818
|
+
try {
|
|
43819
|
+
const pg = new PgAdapterAsync2(getConnectionString2("postgres"));
|
|
43820
|
+
await pg.get("SELECT 1 as ok");
|
|
43821
|
+
lines.push("PostgreSQL: connected");
|
|
43822
|
+
await pg.close();
|
|
43823
|
+
} catch (err) {
|
|
43824
|
+
lines.push(`PostgreSQL: failed \u2014 ${err?.message}`);
|
|
43825
|
+
}
|
|
43826
|
+
}
|
|
43827
|
+
try {
|
|
43828
|
+
const local = new SqliteAdapter2(cloudGetDbPath("conversations"));
|
|
43829
|
+
ensureConflictsTable2(local);
|
|
43830
|
+
const unresolved = listConflicts2(local, { resolved: false });
|
|
43831
|
+
const resolved = listConflicts2(local, { resolved: true });
|
|
43832
|
+
lines.push(`Sync conflicts: ${unresolved.length} unresolved, ${resolved.length} resolved`);
|
|
43833
|
+
local.close();
|
|
43834
|
+
} catch {}
|
|
43835
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
43836
|
+
`) }] };
|
|
43837
|
+
} catch (e) {
|
|
43838
|
+
return { content: [{ type: "text", text: formatError2(e) }], isError: true };
|
|
43839
|
+
}
|
|
43840
|
+
});
|
|
43841
|
+
server.tool("conversations_cloud_push", "Push local conversations data to cloud PostgreSQL. Detects conflicts and syncs all tables.", {
|
|
43842
|
+
tables: exports_external.string().optional().describe("Comma-separated table names (default: all)")
|
|
43843
|
+
}, async ({ tables: tablesStr }) => {
|
|
43844
|
+
try {
|
|
43845
|
+
const {
|
|
43846
|
+
getCloudConfig: getCloudConfig2,
|
|
43847
|
+
getConnectionString: getConnectionString2,
|
|
43848
|
+
syncPush: syncPush2,
|
|
43849
|
+
listSqliteTables: listSqliteTables2,
|
|
43850
|
+
SqliteAdapter: SqliteAdapter2,
|
|
43851
|
+
PgAdapterAsync: PgAdapterAsync2,
|
|
43852
|
+
getDbPath: cloudGetDbPath
|
|
43853
|
+
} = await Promise.resolve().then(() => (init_dist(), exports_dist));
|
|
43854
|
+
const config2 = getCloudConfig2();
|
|
43855
|
+
if (config2.mode === "local") {
|
|
43856
|
+
return { content: [{ type: "text", text: "Error: cloud mode not configured." }], isError: true };
|
|
43857
|
+
}
|
|
43858
|
+
const localPath = cloudGetDbPath("conversations");
|
|
43859
|
+
const local = new SqliteAdapter2(localPath);
|
|
43860
|
+
const cloud = new PgAdapterAsync2(getConnectionString2("conversations"));
|
|
43861
|
+
const tableList = tablesStr ? tablesStr.split(",").map((t) => t.trim()) : listSqliteTables2(local).filter((t) => !SYNC_EXCLUDED.has(t));
|
|
43862
|
+
let totalConflicts = 0;
|
|
43863
|
+
for (const table of tableList) {
|
|
43864
|
+
totalConflicts += await detectAndLogConflicts(local, cloud, table);
|
|
43865
|
+
}
|
|
43866
|
+
const results = await syncPush2(local, cloud, { tables: tableList });
|
|
43867
|
+
local.close();
|
|
43868
|
+
await cloud.close();
|
|
43869
|
+
const total = results.reduce((s, r) => s + r.rowsWritten, 0);
|
|
43870
|
+
const errors3 = results.flatMap((r) => r.errors);
|
|
43871
|
+
const lines = [`Pushed ${total} rows across ${tableList.length} table(s).`];
|
|
43872
|
+
if (totalConflicts > 0)
|
|
43873
|
+
lines.push(`Conflicts detected: ${totalConflicts} (logged to _sync_conflicts)`);
|
|
43874
|
+
if (errors3.length > 0)
|
|
43875
|
+
lines.push(`Errors: ${errors3.join("; ")}`);
|
|
43876
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
43877
|
+
`) }] };
|
|
43878
|
+
} catch (e) {
|
|
43879
|
+
return { content: [{ type: "text", text: formatError2(e) }], isError: true };
|
|
43880
|
+
}
|
|
43881
|
+
});
|
|
43882
|
+
server.tool("conversations_cloud_pull", "Pull cloud PostgreSQL data to local. Detects conflicts and merges by primary key with UPSERT.", {
|
|
43883
|
+
tables: exports_external.string().optional().describe("Comma-separated table names (default: all)")
|
|
43884
|
+
}, async ({ tables: tablesStr }) => {
|
|
43885
|
+
try {
|
|
43886
|
+
const {
|
|
43887
|
+
getCloudConfig: getCloudConfig2,
|
|
43888
|
+
getConnectionString: getConnectionString2,
|
|
43889
|
+
syncPull: syncPull2,
|
|
43890
|
+
listPgTables: listPgTables2,
|
|
43891
|
+
SqliteAdapter: SqliteAdapter2,
|
|
43892
|
+
PgAdapterAsync: PgAdapterAsync2,
|
|
43893
|
+
getDbPath: cloudGetDbPath
|
|
43894
|
+
} = await Promise.resolve().then(() => (init_dist(), exports_dist));
|
|
43895
|
+
const config2 = getCloudConfig2();
|
|
43896
|
+
if (config2.mode === "local") {
|
|
43897
|
+
return { content: [{ type: "text", text: "Error: cloud mode not configured." }], isError: true };
|
|
43898
|
+
}
|
|
43899
|
+
const local = new SqliteAdapter2(cloudGetDbPath("conversations"));
|
|
43900
|
+
const cloud = new PgAdapterAsync2(getConnectionString2("conversations"));
|
|
43901
|
+
let tableList;
|
|
43902
|
+
if (tablesStr) {
|
|
43903
|
+
tableList = tablesStr.split(",").map((t) => t.trim());
|
|
43904
|
+
} else {
|
|
43905
|
+
try {
|
|
43906
|
+
tableList = (await listPgTables2(cloud)).filter((t) => !SYNC_EXCLUDED.has(t));
|
|
43907
|
+
} catch {
|
|
43908
|
+
local.close();
|
|
43909
|
+
await cloud.close();
|
|
43910
|
+
return { content: [{ type: "text", text: "Error: failed to list cloud tables." }], isError: true };
|
|
43911
|
+
}
|
|
43912
|
+
}
|
|
43913
|
+
let totalConflicts = 0;
|
|
43914
|
+
for (const table of tableList) {
|
|
43915
|
+
totalConflicts += await detectAndLogConflicts(local, cloud, table);
|
|
43916
|
+
}
|
|
43917
|
+
const results = await syncPull2(cloud, local, { tables: tableList });
|
|
43918
|
+
local.close();
|
|
43919
|
+
await cloud.close();
|
|
43920
|
+
const total = results.reduce((s, r) => s + r.rowsWritten, 0);
|
|
43921
|
+
const errors3 = results.flatMap((r) => r.errors);
|
|
43922
|
+
const lines = [`Pulled ${total} rows across ${tableList.length} table(s).`];
|
|
43923
|
+
if (totalConflicts > 0)
|
|
43924
|
+
lines.push(`Conflicts detected: ${totalConflicts} (logged to _sync_conflicts)`);
|
|
43925
|
+
if (errors3.length > 0)
|
|
43926
|
+
lines.push(`Errors: ${errors3.join("; ")}`);
|
|
43927
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
43928
|
+
`) }] };
|
|
43929
|
+
} catch (e) {
|
|
43930
|
+
return { content: [{ type: "text", text: formatError2(e) }], isError: true };
|
|
43931
|
+
}
|
|
43932
|
+
});
|
|
43933
|
+
server.tool("conversations_cloud_sync", "Bidirectional cloud sync \u2014 pull remote changes then push local changes. Detects and logs conflicts.", {
|
|
43934
|
+
tables: exports_external.string().optional().describe("Comma-separated table names (default: all syncable tables)")
|
|
43935
|
+
}, async ({ tables: tablesStr }) => {
|
|
43936
|
+
try {
|
|
43937
|
+
const {
|
|
43938
|
+
getCloudConfig: getCloudConfig2,
|
|
43939
|
+
getConnectionString: getConnectionString2,
|
|
43940
|
+
syncPush: syncPush2,
|
|
43941
|
+
syncPull: syncPull2,
|
|
43942
|
+
listSqliteTables: listSqliteTables2,
|
|
43943
|
+
listPgTables: listPgTables2,
|
|
43944
|
+
SqliteAdapter: SqliteAdapter2,
|
|
43945
|
+
PgAdapterAsync: PgAdapterAsync2,
|
|
43946
|
+
getDbPath: cloudGetDbPath
|
|
43947
|
+
} = await Promise.resolve().then(() => (init_dist(), exports_dist));
|
|
43948
|
+
const config2 = getCloudConfig2();
|
|
43949
|
+
if (config2.mode === "local") {
|
|
43950
|
+
return { content: [{ type: "text", text: "Error: cloud mode not configured." }], isError: true };
|
|
43951
|
+
}
|
|
43952
|
+
const local = new SqliteAdapter2(cloudGetDbPath("conversations"));
|
|
43953
|
+
const cloud = new PgAdapterAsync2(getConnectionString2("conversations"));
|
|
43954
|
+
let tableList;
|
|
43955
|
+
if (tablesStr) {
|
|
43956
|
+
tableList = tablesStr.split(",").map((t) => t.trim());
|
|
43957
|
+
} else {
|
|
43958
|
+
const localTables = new Set(listSqliteTables2(local).filter((t) => !SYNC_EXCLUDED.has(t)));
|
|
43959
|
+
let remoteTables;
|
|
43960
|
+
try {
|
|
43961
|
+
remoteTables = new Set((await listPgTables2(cloud)).filter((t) => !SYNC_EXCLUDED.has(t)));
|
|
43962
|
+
} catch {
|
|
43963
|
+
local.close();
|
|
43964
|
+
await cloud.close();
|
|
43965
|
+
return { content: [{ type: "text", text: "Error: failed to list cloud tables." }], isError: true };
|
|
43966
|
+
}
|
|
43967
|
+
tableList = [...new Set([...localTables, ...remoteTables])];
|
|
43968
|
+
}
|
|
43969
|
+
let totalConflicts = 0;
|
|
43970
|
+
for (const table of tableList) {
|
|
43971
|
+
totalConflicts += await detectAndLogConflicts(local, cloud, table);
|
|
43972
|
+
}
|
|
43973
|
+
const pullResults = await syncPull2(cloud, local, { tables: tableList });
|
|
43974
|
+
const pullTotal = pullResults.reduce((s, r) => s + r.rowsWritten, 0);
|
|
43975
|
+
const pushResults = await syncPush2(local, cloud, { tables: tableList });
|
|
43976
|
+
const pushTotal = pushResults.reduce((s, r) => s + r.rowsWritten, 0);
|
|
43977
|
+
local.close();
|
|
43978
|
+
await cloud.close();
|
|
43979
|
+
const allErrors = [
|
|
43980
|
+
...pullResults.flatMap((r) => r.errors.map((e) => `pull: ${e}`)),
|
|
43981
|
+
...pushResults.flatMap((r) => r.errors.map((e) => `push: ${e}`))
|
|
43982
|
+
];
|
|
43983
|
+
const lines = [
|
|
43984
|
+
`Sync complete: pulled ${pullTotal} rows, pushed ${pushTotal} rows across ${tableList.length} table(s).`
|
|
43985
|
+
];
|
|
43986
|
+
if (totalConflicts > 0)
|
|
43987
|
+
lines.push(`Conflicts detected: ${totalConflicts} (logged to _sync_conflicts)`);
|
|
43988
|
+
if (allErrors.length > 0)
|
|
43989
|
+
lines.push(`Errors: ${allErrors.join("; ")}`);
|
|
43990
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
43991
|
+
`) }] };
|
|
43992
|
+
} catch (e) {
|
|
43993
|
+
return { content: [{ type: "text", text: formatError2(e) }], isError: true };
|
|
43994
|
+
}
|
|
43995
|
+
});
|
|
43996
|
+
server.tool("conversations_cloud_migrate", "Run PostgreSQL migrations against the configured RDS instance to initialize the cloud schema", {
|
|
43997
|
+
dry_run: exports_external.boolean().optional().describe("Print SQL without executing")
|
|
43998
|
+
}, async ({ dry_run }) => {
|
|
43999
|
+
try {
|
|
44000
|
+
const { getCloudConfig: getCloudConfig2, getConnectionString: getConnectionString2, PgAdapterAsync: PgAdapterAsync2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
|
|
44001
|
+
const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
|
|
44002
|
+
const config2 = getCloudConfig2();
|
|
44003
|
+
if (config2.mode === "local") {
|
|
44004
|
+
return { content: [{ type: "text", text: "Error: cloud mode not configured." }], isError: true };
|
|
44005
|
+
}
|
|
44006
|
+
if (dry_run) {
|
|
44007
|
+
return { content: [{ type: "text", text: PG_MIGRATIONS2.join(`
|
|
44008
|
+
|
|
44009
|
+
---
|
|
44010
|
+
|
|
44011
|
+
`) }] };
|
|
44012
|
+
}
|
|
44013
|
+
const pg = new PgAdapterAsync2(getConnectionString2("conversations"));
|
|
44014
|
+
const lines = [];
|
|
44015
|
+
for (let i = 0;i < PG_MIGRATIONS2.length; i++) {
|
|
44016
|
+
await pg.run(PG_MIGRATIONS2[i]);
|
|
44017
|
+
lines.push(`Migration ${i + 1}/${PG_MIGRATIONS2.length}: applied`);
|
|
44018
|
+
}
|
|
44019
|
+
await pg.close();
|
|
44020
|
+
lines.push("All migrations applied successfully.");
|
|
44021
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
44022
|
+
`) }] };
|
|
44023
|
+
} catch (e) {
|
|
44024
|
+
return { content: [{ type: "text", text: formatError2(e) }], isError: true };
|
|
44025
|
+
}
|
|
44026
|
+
});
|
|
44027
|
+
server.tool("conversations_cloud_feedback", "Send feedback for the conversations service", {
|
|
44028
|
+
message: exports_external.string().describe("Feedback message"),
|
|
44029
|
+
email: exports_external.string().optional().describe("Contact email")
|
|
44030
|
+
}, async ({ message, email: email3 }) => {
|
|
44031
|
+
try {
|
|
44032
|
+
const { sendFeedback: sendFeedback2, createDatabase: createDatabase2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
|
|
44033
|
+
const db2 = createDatabase2({ service: "cloud" });
|
|
44034
|
+
const result = await sendFeedback2({ service: "conversations", message, email: email3 }, db2);
|
|
44035
|
+
db2.close();
|
|
44036
|
+
return {
|
|
44037
|
+
content: [{
|
|
44038
|
+
type: "text",
|
|
44039
|
+
text: result.sent ? `Feedback sent (id: ${result.id})` : `Saved locally (id: ${result.id}): ${result.error}`
|
|
44040
|
+
}]
|
|
44041
|
+
};
|
|
44042
|
+
} catch (e) {
|
|
44043
|
+
return { content: [{ type: "text", text: formatError2(e) }], isError: true };
|
|
44044
|
+
}
|
|
44045
|
+
});
|
|
44046
|
+
}
|
|
44047
|
+
function formatError2(e) {
|
|
44048
|
+
if (e instanceof Error)
|
|
44049
|
+
return e.message;
|
|
44050
|
+
return String(e);
|
|
44051
|
+
}
|
|
42507
44052
|
// package.json
|
|
42508
44053
|
var package_default = {
|
|
42509
44054
|
name: "@hasna/conversations",
|
|
42510
|
-
version: "0.2.
|
|
44055
|
+
version: "0.2.26",
|
|
42511
44056
|
description: "Real-time CLI messaging for AI agents",
|
|
42512
44057
|
type: "module",
|
|
42513
44058
|
bin: {
|
|
@@ -42536,7 +44081,7 @@ var package_default = {
|
|
|
42536
44081
|
test: "bun test",
|
|
42537
44082
|
dev: "bun run ./src/cli/index.tsx",
|
|
42538
44083
|
typecheck: "tsc --noEmit",
|
|
42539
|
-
prepublishOnly: "bun run build",
|
|
44084
|
+
prepublishOnly: "bun run build:dashboard && bun run build",
|
|
42540
44085
|
postinstall: "mkdir -p $HOME/.hasna/conversations $HOME/.hasna/conversations/training 2>/dev/null || true"
|
|
42541
44086
|
},
|
|
42542
44087
|
keywords: [
|
|
@@ -42611,7 +44156,7 @@ registerAgentTools(server, agentFocus, getAgentFocus);
|
|
|
42611
44156
|
registerAdvancedTools(server, package_default.version);
|
|
42612
44157
|
async function startMcpServer() {
|
|
42613
44158
|
const transport = new StdioServerTransport;
|
|
42614
|
-
|
|
44159
|
+
registerCloudSyncTools(server);
|
|
42615
44160
|
await server.connect(transport);
|
|
42616
44161
|
}
|
|
42617
44162
|
var isDirectRun = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("mcp.js") || process.argv[1]?.endsWith("mcp.ts");
|