@hasna/todos 0.11.7 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp/index.js CHANGED
@@ -40,6 +40,64 @@ var __export = (target, all) => {
40
40
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
41
41
 
42
42
  // node_modules/@hasna/cloud/dist/index.js
43
+ var exports_dist = {};
44
+ __export(exports_dist, {
45
+ translateSql: () => translateSql,
46
+ translateParams: () => translateParams,
47
+ translateDdl: () => translateDdl,
48
+ syncPush: () => syncPush,
49
+ syncPull: () => syncPull,
50
+ storeConflicts: () => storeConflicts,
51
+ setupAutoSync: () => setupAutoSync,
52
+ sendFeedback: () => sendFeedback,
53
+ saveFeedback: () => saveFeedback,
54
+ saveCloudConfig: () => saveCloudConfig,
55
+ runScheduledSync: () => runScheduledSync,
56
+ resolveConflicts: () => resolveConflicts,
57
+ resolveConflict: () => resolveConflict,
58
+ resetSyncMeta: () => resetSyncMeta,
59
+ resetAllSyncMeta: () => resetAllSyncMeta,
60
+ removeSyncSchedule: () => removeSyncSchedule,
61
+ registerSyncSchedule: () => registerSyncSchedule,
62
+ registerCloudTools: () => registerCloudTools,
63
+ registerCloudCommands: () => registerCloudCommands,
64
+ purgeResolvedConflicts: () => purgeResolvedConflicts,
65
+ parseInterval: () => parseInterval,
66
+ minutesToCron: () => minutesToCron,
67
+ migrateDotfile: () => migrateDotfile,
68
+ listSqliteTables: () => listSqliteTables,
69
+ listPgTables: () => listPgTables,
70
+ listFeedback: () => listFeedback,
71
+ listConflicts: () => listConflicts,
72
+ incrementalSyncPush: () => incrementalSyncPush,
73
+ incrementalSyncPull: () => incrementalSyncPull,
74
+ hasLegacyDotfile: () => hasLegacyDotfile,
75
+ getWinningData: () => getWinningData,
76
+ getSyncScheduleStatus: () => getSyncScheduleStatus,
77
+ getSyncMetaForTable: () => getSyncMetaForTable,
78
+ getSyncMetaAll: () => getSyncMetaAll,
79
+ getHasnaDir: () => getHasnaDir,
80
+ getDbPath: () => getDbPath,
81
+ getDataDir: () => getDataDir,
82
+ getConnectionString: () => getConnectionString,
83
+ getConflict: () => getConflict,
84
+ getConfigPath: () => getConfigPath,
85
+ getConfigDir: () => getConfigDir,
86
+ getCloudConfig: () => getCloudConfig,
87
+ getAutoSyncConfig: () => getAutoSyncConfig,
88
+ ensureSyncMetaTable: () => ensureSyncMetaTable,
89
+ ensureFeedbackTable: () => ensureFeedbackTable,
90
+ ensureConflictsTable: () => ensureConflictsTable,
91
+ enableAutoSync: () => enableAutoSync,
92
+ discoverSyncableServices: () => discoverSyncableServices,
93
+ detectConflicts: () => detectConflicts,
94
+ createDatabase: () => createDatabase,
95
+ SyncProgressTracker: () => SyncProgressTracker,
96
+ SqliteAdapter: () => SqliteAdapter,
97
+ PgAdapterAsync: () => PgAdapterAsync,
98
+ PgAdapter: () => PgAdapter,
99
+ CloudConfigSchema: () => CloudConfigSchema
100
+ });
43
101
  import { createRequire } from "module";
44
102
  import { Database } from "bun:sqlite";
45
103
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
@@ -54,8 +112,12 @@ import {
54
112
  import { homedir } from "os";
55
113
  import { join, relative } from "path";
56
114
  import { hostname } from "os";
115
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
57
116
  import { homedir as homedir3 } from "os";
58
117
  import { join as join3 } from "path";
118
+ import { existsSync as existsSync4, readdirSync as readdirSync2 } from "fs";
119
+ import { join as join4 } from "path";
120
+ import { join as join5, dirname } from "path";
59
121
  function __accessProp2(key) {
60
122
  return this[key];
61
123
  }
@@ -96,6 +158,17 @@ function sqliteToPostgres(sql) {
96
158
  out = out.replace(/INSERT\s+OR\s+IGNORE\s+INTO/gi, "INSERT INTO");
97
159
  return out;
98
160
  }
161
+ function translateDdl(ddl, dialect) {
162
+ if (dialect === "sqlite")
163
+ return ddl;
164
+ let out = ddl;
165
+ out = out.replace(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/gi, "BIGSERIAL PRIMARY KEY");
166
+ out = out.replace(/\bAUTOINCREMENT\b/gi, "");
167
+ out = out.replace(/\bREAL\b/gi, "DOUBLE PRECISION");
168
+ out = out.replace(/\bBLOB\b/gi, "BYTEA");
169
+ out = sqliteToPostgres(out);
170
+ return out;
171
+ }
99
172
 
100
173
  class SqliteAdapter {
101
174
  db;
@@ -919,6 +992,45 @@ function getDbPath(serviceName) {
919
992
  const dir = getDataDir(serviceName);
920
993
  return join(dir, `${serviceName}.db`);
921
994
  }
995
+ function migrateDotfile(serviceName) {
996
+ const legacyDir = join(homedir(), `.${serviceName}`);
997
+ const newDir = join(HASNA_DIR, serviceName);
998
+ if (!existsSync(legacyDir))
999
+ return [];
1000
+ if (existsSync(newDir))
1001
+ return [];
1002
+ mkdirSync(newDir, { recursive: true });
1003
+ const migrated = [];
1004
+ copyDirRecursive(legacyDir, newDir, legacyDir, migrated);
1005
+ return migrated;
1006
+ }
1007
+ function copyDirRecursive(src, dest, root, migrated) {
1008
+ const entries = readdirSync(src, { withFileTypes: true });
1009
+ for (const entry of entries) {
1010
+ const srcPath = join(src, entry.name);
1011
+ const destPath = join(dest, entry.name);
1012
+ if (entry.isDirectory()) {
1013
+ mkdirSync(destPath, { recursive: true });
1014
+ copyDirRecursive(srcPath, destPath, root, migrated);
1015
+ } else {
1016
+ copyFileSync(srcPath, destPath);
1017
+ migrated.push(relative(root, srcPath));
1018
+ }
1019
+ }
1020
+ }
1021
+ function hasLegacyDotfile(serviceName) {
1022
+ return existsSync(join(homedir(), `.${serviceName}`));
1023
+ }
1024
+ function getHasnaDir() {
1025
+ mkdirSync(HASNA_DIR, { recursive: true });
1026
+ return HASNA_DIR;
1027
+ }
1028
+ function getConfigDir() {
1029
+ return CONFIG_DIR;
1030
+ }
1031
+ function getConfigPath() {
1032
+ return CONFIG_PATH;
1033
+ }
922
1034
  function getCloudConfig() {
923
1035
  if (!existsSync2(CONFIG_PATH)) {
924
1036
  return CloudConfigSchema.parse({});
@@ -930,6 +1042,11 @@ function getCloudConfig() {
930
1042
  return CloudConfigSchema.parse({});
931
1043
  }
932
1044
  }
1045
+ function saveCloudConfig(config) {
1046
+ mkdirSync2(CONFIG_DIR, { recursive: true });
1047
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + `
1048
+ `, "utf-8");
1049
+ }
933
1050
  function getConnectionString(dbName) {
934
1051
  const config = getCloudConfig();
935
1052
  const { host, port, username, password_env, ssl } = config.rds;
@@ -1079,42 +1196,100 @@ async function syncTransfer(source, target, options, _direction) {
1079
1196
  primaryKey: pkOption
1080
1197
  } = options;
1081
1198
  const results = [];
1082
- for (let i = 0;i < tables.length; i++) {
1083
- const table = tables[i];
1084
- const result = {
1085
- table,
1086
- rowsRead: 0,
1087
- rowsWritten: 0,
1088
- rowsSkipped: 0,
1089
- errors: []
1090
- };
1199
+ const sqliteTarget = !isAsyncAdapter(target) ? target : null;
1200
+ if (sqliteTarget) {
1091
1201
  try {
1092
- onProgress?.({
1202
+ sqliteTarget.exec("PRAGMA foreign_keys = OFF");
1203
+ } catch {}
1204
+ }
1205
+ try {
1206
+ for (let i = 0;i < tables.length; i++) {
1207
+ const table = tables[i];
1208
+ const result = {
1093
1209
  table,
1094
- phase: "reading",
1095
1210
  rowsRead: 0,
1096
1211
  rowsWritten: 0,
1097
- totalTables: tables.length,
1098
- currentTableIndex: i
1099
- });
1100
- const rows = await readAll(source, `SELECT * FROM "${table}"`);
1101
- result.rowsRead = rows.length;
1102
- if (rows.length === 0) {
1212
+ rowsSkipped: 0,
1213
+ errors: []
1214
+ };
1215
+ try {
1103
1216
  onProgress?.({
1104
1217
  table,
1105
- phase: "done",
1218
+ phase: "reading",
1106
1219
  rowsRead: 0,
1107
1220
  rowsWritten: 0,
1108
1221
  totalTables: tables.length,
1109
1222
  currentTableIndex: i
1110
1223
  });
1111
- results.push(result);
1112
- continue;
1113
- }
1114
- const pkColumns = await resolvePrimaryKeys(source, target, table, pkOption);
1115
- const columns = Object.keys(rows[0]);
1116
- if (pkColumns.length === 0) {
1117
- result.errors.push(`Table "${table}" has no primary key \u2014 inserting without conflict handling`);
1224
+ const rows = await readAll(source, `SELECT * FROM "${table}"`);
1225
+ result.rowsRead = rows.length;
1226
+ if (rows.length === 0) {
1227
+ onProgress?.({
1228
+ table,
1229
+ phase: "done",
1230
+ rowsRead: 0,
1231
+ rowsWritten: 0,
1232
+ totalTables: tables.length,
1233
+ currentTableIndex: i
1234
+ });
1235
+ results.push(result);
1236
+ continue;
1237
+ }
1238
+ const pkColumns = await resolvePrimaryKeys(source, target, table, pkOption);
1239
+ const sourceColumns = Object.keys(rows[0]);
1240
+ let targetColumns = null;
1241
+ if (!isAsyncAdapter(target)) {
1242
+ try {
1243
+ const colInfo = target.all(`PRAGMA table_info("${table}")`);
1244
+ targetColumns = new Set(colInfo.map((c) => c.name));
1245
+ } catch {}
1246
+ } else {
1247
+ try {
1248
+ const colInfo = await target.all(`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${table}'`);
1249
+ targetColumns = new Set(colInfo.map((c) => c.column_name));
1250
+ } catch {}
1251
+ }
1252
+ const columns = targetColumns ? sourceColumns.filter((c) => targetColumns.has(c)) : sourceColumns;
1253
+ if (pkColumns.length === 0) {
1254
+ result.errors.push(`Table "${table}" has no primary key \u2014 inserting without conflict handling`);
1255
+ onProgress?.({
1256
+ table,
1257
+ phase: "writing",
1258
+ rowsRead: result.rowsRead,
1259
+ rowsWritten: 0,
1260
+ totalTables: tables.length,
1261
+ currentTableIndex: i
1262
+ });
1263
+ for (let offset = 0;offset < rows.length; offset += batchSize) {
1264
+ const batch = rows.slice(offset, offset + batchSize);
1265
+ try {
1266
+ if (isAsyncAdapter(target)) {
1267
+ await batchInsertPg(target, table, columns, batch);
1268
+ } else {
1269
+ batchInsertSqlite(target, table, columns, batch);
1270
+ }
1271
+ result.rowsWritten += batch.length;
1272
+ } catch (err) {
1273
+ result.errors.push(`Batch at offset ${offset}: ${err?.message ?? String(err)}`);
1274
+ }
1275
+ }
1276
+ onProgress?.({
1277
+ table,
1278
+ phase: "done",
1279
+ rowsRead: result.rowsRead,
1280
+ rowsWritten: result.rowsWritten,
1281
+ totalTables: tables.length,
1282
+ currentTableIndex: i
1283
+ });
1284
+ results.push(result);
1285
+ continue;
1286
+ }
1287
+ const missingPks = pkColumns.filter((pk) => !columns.includes(pk));
1288
+ if (missingPks.length > 0) {
1289
+ result.errors.push(`Table "${table}" missing PK columns in data: ${missingPks.join(", ")} \u2014 skipping`);
1290
+ results.push(result);
1291
+ continue;
1292
+ }
1118
1293
  onProgress?.({
1119
1294
  table,
1120
1295
  phase: "writing",
@@ -1123,18 +1298,27 @@ async function syncTransfer(source, target, options, _direction) {
1123
1298
  totalTables: tables.length,
1124
1299
  currentTableIndex: i
1125
1300
  });
1301
+ const updateCols = columns.filter((c) => !pkColumns.includes(c));
1126
1302
  for (let offset = 0;offset < rows.length; offset += batchSize) {
1127
1303
  const batch = rows.slice(offset, offset + batchSize);
1128
1304
  try {
1129
1305
  if (isAsyncAdapter(target)) {
1130
- await batchInsertPg(target, table, columns, batch);
1306
+ await batchUpsertPg(target, table, columns, updateCols, pkColumns, batch);
1131
1307
  } else {
1132
- batchInsertSqlite(target, table, columns, batch);
1308
+ batchUpsertSqlite(target, table, columns, updateCols, pkColumns, batch);
1133
1309
  }
1134
1310
  result.rowsWritten += batch.length;
1135
1311
  } catch (err) {
1136
1312
  result.errors.push(`Batch at offset ${offset}: ${err?.message ?? String(err)}`);
1137
1313
  }
1314
+ onProgress?.({
1315
+ table,
1316
+ phase: "writing",
1317
+ rowsRead: result.rowsRead,
1318
+ rowsWritten: result.rowsWritten,
1319
+ totalTables: tables.length,
1320
+ currentTableIndex: i
1321
+ });
1138
1322
  }
1139
1323
  onProgress?.({
1140
1324
  table,
@@ -1144,57 +1328,27 @@ async function syncTransfer(source, target, options, _direction) {
1144
1328
  totalTables: tables.length,
1145
1329
  currentTableIndex: i
1146
1330
  });
1147
- results.push(result);
1148
- continue;
1149
- }
1150
- const missingPks = pkColumns.filter((pk) => !columns.includes(pk));
1151
- if (missingPks.length > 0) {
1152
- result.errors.push(`Table "${table}" missing PK columns in data: ${missingPks.join(", ")} \u2014 skipping`);
1153
- results.push(result);
1154
- continue;
1331
+ } catch (err) {
1332
+ result.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
1155
1333
  }
1156
- onProgress?.({
1157
- table,
1158
- phase: "writing",
1159
- rowsRead: result.rowsRead,
1160
- rowsWritten: 0,
1161
- totalTables: tables.length,
1162
- currentTableIndex: i
1163
- });
1164
- const updateCols = columns.filter((c) => !pkColumns.includes(c));
1165
- for (let offset = 0;offset < rows.length; offset += batchSize) {
1166
- const batch = rows.slice(offset, offset + batchSize);
1167
- try {
1168
- if (isAsyncAdapter(target)) {
1169
- await batchUpsertPg(target, table, columns, updateCols, pkColumns, batch);
1170
- } else {
1171
- batchUpsertSqlite(target, table, columns, updateCols, pkColumns, batch);
1334
+ results.push(result);
1335
+ }
1336
+ } finally {
1337
+ if (sqliteTarget) {
1338
+ try {
1339
+ sqliteTarget.exec("PRAGMA foreign_keys = ON");
1340
+ } catch {}
1341
+ try {
1342
+ const violations = sqliteTarget.all("PRAGMA foreign_key_check");
1343
+ if (violations.length > 0) {
1344
+ const tables2 = [...new Set(violations.map((v) => v.table))];
1345
+ const msg = `FK integrity check: ${violations.length} violation(s) in table(s): ${tables2.join(", ")}`;
1346
+ if (results.length > 0) {
1347
+ results[results.length - 1].errors.push(msg);
1172
1348
  }
1173
- result.rowsWritten += batch.length;
1174
- } catch (err) {
1175
- result.errors.push(`Batch at offset ${offset}: ${err?.message ?? String(err)}`);
1176
1349
  }
1177
- onProgress?.({
1178
- table,
1179
- phase: "writing",
1180
- rowsRead: result.rowsRead,
1181
- rowsWritten: result.rowsWritten,
1182
- totalTables: tables.length,
1183
- currentTableIndex: i
1184
- });
1185
- }
1186
- onProgress?.({
1187
- table,
1188
- phase: "done",
1189
- rowsRead: result.rowsRead,
1190
- rowsWritten: result.rowsWritten,
1191
- totalTables: tables.length,
1192
- currentTableIndex: i
1193
- });
1194
- } catch (err) {
1195
- result.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
1350
+ } catch {}
1196
1351
  }
1197
- results.push(result);
1198
1352
  }
1199
1353
  return results;
1200
1354
  }
@@ -1222,7 +1376,7 @@ function batchUpsertSqlite(target, table, columns, updateCols, primaryKeys, batc
1222
1376
  const setClause = updateCols.length > 0 ? updateCols.map((c) => `"${c}" = EXCLUDED."${c}"`).join(", ") : `"${primaryKeys[0]}" = EXCLUDED."${primaryKeys[0]}"`;
1223
1377
  const sql = `INSERT INTO "${table}" (${colList}) VALUES ${valuePlaceholders}
1224
1378
  ON CONFLICT (${pkList}) DO UPDATE SET ${setClause}`;
1225
- const params = batch.flatMap((row) => columns.map((c) => row[c] ?? null));
1379
+ const params = batch.flatMap((row) => columns.map((c) => coerceForSqlite(row[c])));
1226
1380
  target.run(sql, ...params);
1227
1381
  }
1228
1382
  async function batchInsertPg(target, table, columns, batch) {
@@ -1243,9 +1397,22 @@ function batchInsertSqlite(target, table, columns, batch) {
1243
1397
  const colList = columns.map((c) => `"${c}"`).join(", ");
1244
1398
  const valuePlaceholders = batch.map(() => `(${columns.map(() => "?").join(", ")})`).join(", ");
1245
1399
  const sql = `INSERT INTO "${table}" (${colList}) VALUES ${valuePlaceholders}`;
1246
- const params = batch.flatMap((row) => columns.map((c) => row[c] ?? null));
1400
+ const params = batch.flatMap((row) => columns.map((c) => coerceForSqlite(row[c])));
1247
1401
  target.run(sql, ...params);
1248
1402
  }
1403
+ function coerceForSqlite(value) {
1404
+ if (value === null || value === undefined)
1405
+ return null;
1406
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
1407
+ return value;
1408
+ if (value instanceof Date)
1409
+ return value.toISOString();
1410
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
1411
+ return value;
1412
+ if (typeof value === "object")
1413
+ return JSON.stringify(value);
1414
+ return String(value);
1415
+ }
1249
1416
  function isAsyncAdapter(adapter) {
1250
1417
  return adapter.constructor.name === "PgAdapterAsync" || typeof adapter.raw?.connect === "function";
1251
1418
  }
@@ -1313,6 +1480,10 @@ async function sendFeedback(feedback, db) {
1313
1480
  return { sent: false, id, error: errorMsg };
1314
1481
  }
1315
1482
  }
1483
+ function listFeedback(db) {
1484
+ ensureFeedbackTable(db);
1485
+ return db.all(`SELECT id, service, version, message, email, machine_id, created_at FROM feedback ORDER BY created_at DESC`);
1486
+ }
1316
1487
 
1317
1488
  class SyncProgressTracker {
1318
1489
  db;
@@ -1433,105 +1604,749 @@ class SyncProgressTracker {
1433
1604
  }
1434
1605
  }
1435
1606
  }
1436
- function registerCloudTools(server, serviceName) {
1437
- server.tool(`${serviceName}_cloud_status`, "Show cloud configuration and connection health", {}, async () => {
1438
- const config = getCloudConfig();
1439
- const lines = [
1440
- `Mode: ${config.mode}`,
1441
- `Service: ${serviceName}`,
1442
- `RDS Host: ${config.rds.host || "(not configured)"}`
1443
- ];
1444
- if (config.rds.host && config.rds.username) {
1445
- try {
1446
- const pg2 = new PgAdapterAsync(getConnectionString("postgres"));
1447
- await pg2.get("SELECT 1 as ok");
1448
- lines.push("PostgreSQL: connected");
1449
- await pg2.close();
1450
- } catch (err) {
1451
- lines.push(`PostgreSQL: failed \u2014 ${err?.message}`);
1452
- }
1607
+ function detectConflicts(local, remote, table, primaryKey = "id", conflictColumn = "updated_at") {
1608
+ const conflicts = [];
1609
+ const remoteMap = new Map;
1610
+ for (const row of remote) {
1611
+ const key = String(row[primaryKey]);
1612
+ remoteMap.set(key, row);
1613
+ }
1614
+ for (const localRow of local) {
1615
+ const key = String(localRow[primaryKey]);
1616
+ const remoteRow = remoteMap.get(key);
1617
+ if (!remoteRow)
1618
+ continue;
1619
+ const localTs = localRow[conflictColumn];
1620
+ const remoteTs = remoteRow[conflictColumn];
1621
+ if (localTs !== remoteTs) {
1622
+ conflicts.push({
1623
+ table,
1624
+ row_id: key,
1625
+ local_updated_at: String(localTs ?? ""),
1626
+ remote_updated_at: String(remoteTs ?? ""),
1627
+ local_data: { ...localRow },
1628
+ remote_data: { ...remoteRow },
1629
+ resolved: false
1630
+ });
1453
1631
  }
1454
- return { content: [{ type: "text", text: lines.join(`
1455
- `) }] };
1456
- });
1457
- server.tool(`${serviceName}_cloud_push`, "Push local data to cloud PostgreSQL", {
1458
- tables: exports_external2.string().optional().describe("Comma-separated table names (default: all)")
1459
- }, async ({ tables: tablesStr }) => {
1460
- const config = getCloudConfig();
1461
- if (config.mode === "local") {
1462
- return {
1463
- content: [
1464
- { type: "text", text: "Error: cloud mode not configured." }
1465
- ],
1466
- isError: true
1467
- };
1632
+ }
1633
+ return conflicts;
1634
+ }
1635
+ function resolveConflicts(conflicts, strategy = "newest-wins") {
1636
+ return conflicts.map((conflict) => {
1637
+ const resolved = { ...conflict, resolved: true, resolution: strategy };
1638
+ switch (strategy) {
1639
+ case "local-wins":
1640
+ break;
1641
+ case "remote-wins":
1642
+ break;
1643
+ case "newest-wins": {
1644
+ const localTime = new Date(conflict.local_updated_at).getTime();
1645
+ const remoteTime = new Date(conflict.remote_updated_at).getTime();
1646
+ if (remoteTime > localTime) {
1647
+ resolved.resolution = "newest-wins";
1648
+ } else {
1649
+ resolved.resolution = "newest-wins";
1650
+ }
1651
+ break;
1652
+ }
1468
1653
  }
1469
- const local = new SqliteAdapter(getDbPath(serviceName));
1470
- const cloud = new PgAdapterAsync(getConnectionString(serviceName));
1471
- const tableList = tablesStr ? tablesStr.split(",").map((t) => t.trim()) : listSqliteTables(local);
1472
- const results = await syncPush(local, cloud, { tables: tableList });
1473
- local.close();
1474
- await cloud.close();
1475
- const total = results.reduce((s, r) => s + r.rowsWritten, 0);
1476
- return {
1477
- content: [{ type: "text", text: `Pushed ${total} rows across ${tableList.length} table(s).` }]
1478
- };
1654
+ return resolved;
1479
1655
  });
1480
- server.tool(`${serviceName}_cloud_pull`, "Pull cloud PostgreSQL data to local", {
1481
- tables: exports_external2.string().optional().describe("Comma-separated table names (default: all)")
1482
- }, async ({ tables: tablesStr }) => {
1483
- const config = getCloudConfig();
1484
- if (config.mode === "local") {
1485
- return {
1486
- content: [
1487
- { type: "text", text: "Error: cloud mode not configured." }
1488
- ],
1489
- isError: true
1490
- };
1491
- }
1492
- const local = new SqliteAdapter(getDbPath(serviceName));
1493
- const cloud = new PgAdapterAsync(getConnectionString(serviceName));
1494
- let tableList;
1495
- if (tablesStr) {
1496
- tableList = tablesStr.split(",").map((t) => t.trim());
1656
+ }
1657
+ function getWinningData(conflict) {
1658
+ if (!conflict.resolved || !conflict.resolution) {
1659
+ throw new Error(`Conflict for row ${conflict.row_id} is not resolved`);
1660
+ }
1661
+ switch (conflict.resolution) {
1662
+ case "local-wins":
1663
+ return conflict.local_data;
1664
+ case "remote-wins":
1665
+ return conflict.remote_data;
1666
+ case "newest-wins": {
1667
+ const localTime = new Date(conflict.local_updated_at).getTime();
1668
+ const remoteTime = new Date(conflict.remote_updated_at).getTime();
1669
+ return remoteTime >= localTime ? conflict.remote_data : conflict.local_data;
1670
+ }
1671
+ case "manual":
1672
+ return conflict.local_data;
1673
+ default:
1674
+ return conflict.local_data;
1675
+ }
1676
+ }
1677
+ function ensureConflictsTable(db) {
1678
+ db.exec(`
1679
+ CREATE TABLE IF NOT EXISTS _sync_conflicts (
1680
+ id TEXT PRIMARY KEY,
1681
+ table_name TEXT,
1682
+ row_id TEXT,
1683
+ local_data TEXT,
1684
+ remote_data TEXT,
1685
+ local_updated_at TEXT,
1686
+ remote_updated_at TEXT,
1687
+ resolution TEXT,
1688
+ resolved_at TEXT,
1689
+ created_at TEXT DEFAULT (datetime('now'))
1690
+ )
1691
+ `);
1692
+ }
1693
+ function storeConflicts(db, conflicts) {
1694
+ ensureConflictsTable(db);
1695
+ for (const conflict of conflicts) {
1696
+ const id = `${conflict.table}:${conflict.row_id}:${Date.now()}`;
1697
+ db.run(`INSERT INTO _sync_conflicts (id, table_name, row_id, local_data, remote_data, local_updated_at, remote_updated_at, resolution, resolved_at)
1698
+ 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);
1699
+ }
1700
+ }
1701
+ function listConflicts(db, opts) {
1702
+ ensureConflictsTable(db);
1703
+ let sql = `SELECT * FROM _sync_conflicts WHERE 1=1`;
1704
+ const params = [];
1705
+ if (opts?.resolved !== undefined) {
1706
+ if (opts.resolved) {
1707
+ sql += ` AND resolution IS NOT NULL AND resolved_at IS NOT NULL`;
1497
1708
  } else {
1498
- try {
1499
- tableList = await listPgTables(cloud);
1500
- } catch {
1501
- local.close();
1502
- await cloud.close();
1503
- return {
1504
- content: [
1505
- { type: "text", text: "Error: failed to list cloud tables." }
1506
- ],
1507
- isError: true
1508
- };
1709
+ sql += ` AND (resolution IS NULL OR resolved_at IS NULL)`;
1710
+ }
1711
+ }
1712
+ if (opts?.table) {
1713
+ sql += ` AND table_name = ?`;
1714
+ params.push(opts.table);
1715
+ }
1716
+ sql += ` ORDER BY created_at DESC`;
1717
+ return db.all(sql, ...params);
1718
+ }
1719
+ function resolveConflict(db, conflictId, strategy) {
1720
+ ensureConflictsTable(db);
1721
+ const row = db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
1722
+ if (!row)
1723
+ return null;
1724
+ db.run(`UPDATE _sync_conflicts SET resolution = ?, resolved_at = datetime('now') WHERE id = ?`, strategy, conflictId);
1725
+ return db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
1726
+ }
1727
+ function getConflict(db, conflictId) {
1728
+ ensureConflictsTable(db);
1729
+ return db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
1730
+ }
1731
+ function purgeResolvedConflicts(db) {
1732
+ ensureConflictsTable(db);
1733
+ const result = db.run(`DELETE FROM _sync_conflicts WHERE resolution IS NOT NULL AND resolved_at IS NOT NULL`);
1734
+ return result.changes;
1735
+ }
1736
+ function ensureSyncMetaTable(db) {
1737
+ db.exec(SYNC_META_TABLE_SQL);
1738
+ }
1739
+ function getSyncMeta(db, table) {
1740
+ ensureSyncMetaTable(db);
1741
+ return db.get(`SELECT table_name, last_synced_at, last_synced_row_count, direction FROM _sync_meta WHERE table_name = ?`, table) ?? null;
1742
+ }
1743
+ function upsertSyncMeta(db, meta) {
1744
+ ensureSyncMetaTable(db);
1745
+ const existing = db.get(`SELECT table_name FROM _sync_meta WHERE table_name = ?`, meta.table_name);
1746
+ if (existing) {
1747
+ db.run(`UPDATE _sync_meta SET last_synced_at = ?, last_synced_row_count = ?, direction = ? WHERE table_name = ?`, meta.last_synced_at, meta.last_synced_row_count, meta.direction, meta.table_name);
1748
+ } else {
1749
+ db.run(`INSERT INTO _sync_meta (table_name, last_synced_at, last_synced_row_count, direction) VALUES (?, ?, ?, ?)`, meta.table_name, meta.last_synced_at, meta.last_synced_row_count, meta.direction);
1750
+ }
1751
+ }
1752
+ function transferRows(source, target, table, rows, options) {
1753
+ const { primaryKey = "id", conflictColumn = "updated_at" } = options;
1754
+ let written = 0;
1755
+ let skipped = 0;
1756
+ const errors2 = [];
1757
+ if (rows.length === 0)
1758
+ return { written, skipped, errors: errors2 };
1759
+ const columns = Object.keys(rows[0]);
1760
+ const hasConflictCol = columns.includes(conflictColumn);
1761
+ const hasPrimaryKey = columns.includes(primaryKey);
1762
+ if (!hasPrimaryKey) {
1763
+ errors2.push(`Table "${table}" has no "${primaryKey}" column -- skipping`);
1764
+ return { written, skipped, errors: errors2 };
1765
+ }
1766
+ for (const row of rows) {
1767
+ try {
1768
+ const existing = target.get(`SELECT "${primaryKey}"${hasConflictCol ? `, "${conflictColumn}"` : ""} FROM "${table}" WHERE "${primaryKey}" = ?`, row[primaryKey]);
1769
+ if (existing) {
1770
+ if (hasConflictCol && existing[conflictColumn] && row[conflictColumn]) {
1771
+ const existingTime = new Date(existing[conflictColumn]).getTime();
1772
+ const incomingTime = new Date(row[conflictColumn]).getTime();
1773
+ if (existingTime >= incomingTime) {
1774
+ skipped++;
1775
+ continue;
1776
+ }
1777
+ }
1778
+ const setClauses = columns.filter((c) => c !== primaryKey).map((c) => `"${c}" = ?`).join(", ");
1779
+ const values = columns.filter((c) => c !== primaryKey).map((c) => row[c]);
1780
+ values.push(row[primaryKey]);
1781
+ target.run(`UPDATE "${table}" SET ${setClauses} WHERE "${primaryKey}" = ?`, ...values);
1782
+ } else {
1783
+ const placeholders = columns.map(() => "?").join(", ");
1784
+ const colList = columns.map((c) => `"${c}"`).join(", ");
1785
+ const values = columns.map((c) => row[c]);
1786
+ target.run(`INSERT INTO "${table}" (${colList}) VALUES (${placeholders})`, ...values);
1509
1787
  }
1788
+ written++;
1789
+ } catch (err) {
1790
+ errors2.push(`Row ${row[primaryKey]}: ${err?.message ?? String(err)}`);
1510
1791
  }
1511
- const results = await syncPull(cloud, local, { tables: tableList });
1512
- local.close();
1513
- await cloud.close();
1514
- const total = results.reduce((s, r) => s + r.rowsWritten, 0);
1515
- return {
1516
- content: [{ type: "text", text: `Pulled ${total} rows across ${tableList.length} table(s).` }]
1792
+ }
1793
+ return { written, skipped, errors: errors2 };
1794
+ }
1795
+ function incrementalSyncPush(local, remote, tables, options = {}) {
1796
+ const { conflictColumn = "updated_at", batchSize = 500 } = options;
1797
+ const results = [];
1798
+ ensureSyncMetaTable(local);
1799
+ for (const table of tables) {
1800
+ const stat = {
1801
+ table,
1802
+ total_rows: 0,
1803
+ synced_rows: 0,
1804
+ skipped_rows: 0,
1805
+ errors: [],
1806
+ first_sync: false
1517
1807
  };
1518
- });
1519
- server.tool(`${serviceName}_cloud_feedback`, "Send feedback for this service", {
1520
- message: exports_external2.string().describe("Feedback message"),
1521
- email: exports_external2.string().optional().describe("Contact email")
1522
- }, async ({ message, email }) => {
1523
- const db = createDatabase({ service: "cloud" });
1524
- const result = await sendFeedback({ service: serviceName, message, email }, db);
1525
- db.close();
1526
- return {
1527
- content: [
1528
- {
1529
- type: "text",
1530
- text: result.sent ? `Feedback sent (id: ${result.id})` : `Saved locally (id: ${result.id}): ${result.error}`
1808
+ try {
1809
+ const countResult = local.get(`SELECT COUNT(*) as cnt FROM "${table}"`);
1810
+ stat.total_rows = countResult?.cnt ?? 0;
1811
+ const meta = getSyncMeta(local, table);
1812
+ let rows;
1813
+ if (meta?.last_synced_at) {
1814
+ try {
1815
+ rows = local.all(`SELECT * FROM "${table}" WHERE "${conflictColumn}" > ?`, meta.last_synced_at);
1816
+ } catch {
1817
+ rows = local.all(`SELECT * FROM "${table}"`);
1818
+ stat.first_sync = true;
1531
1819
  }
1532
- ]
1820
+ } else {
1821
+ rows = local.all(`SELECT * FROM "${table}"`);
1822
+ stat.first_sync = true;
1823
+ }
1824
+ for (let offset = 0;offset < rows.length; offset += batchSize) {
1825
+ const batch = rows.slice(offset, offset + batchSize);
1826
+ const result = transferRows(local, remote, table, batch, options);
1827
+ stat.synced_rows += result.written;
1828
+ stat.skipped_rows += result.skipped;
1829
+ stat.errors.push(...result.errors);
1830
+ }
1831
+ if (rows.length === 0) {
1832
+ stat.skipped_rows = stat.total_rows;
1833
+ }
1834
+ const now = new Date().toISOString();
1835
+ upsertSyncMeta(local, {
1836
+ table_name: table,
1837
+ last_synced_at: now,
1838
+ last_synced_row_count: stat.synced_rows,
1839
+ direction: "push"
1840
+ });
1841
+ } catch (err) {
1842
+ stat.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
1843
+ }
1844
+ results.push(stat);
1845
+ }
1846
+ return results;
1847
+ }
1848
+ function incrementalSyncPull(remote, local, tables, options = {}) {
1849
+ const { conflictColumn = "updated_at", batchSize = 500 } = options;
1850
+ const results = [];
1851
+ ensureSyncMetaTable(local);
1852
+ for (const table of tables) {
1853
+ const stat = {
1854
+ table,
1855
+ total_rows: 0,
1856
+ synced_rows: 0,
1857
+ skipped_rows: 0,
1858
+ errors: [],
1859
+ first_sync: false
1533
1860
  };
1534
- });
1861
+ try {
1862
+ const countResult = remote.get(`SELECT COUNT(*) as cnt FROM "${table}"`);
1863
+ stat.total_rows = countResult?.cnt ?? 0;
1864
+ const meta = getSyncMeta(local, table);
1865
+ let rows;
1866
+ if (meta?.last_synced_at) {
1867
+ try {
1868
+ rows = remote.all(`SELECT * FROM "${table}" WHERE "${conflictColumn}" > ?`, meta.last_synced_at);
1869
+ } catch {
1870
+ rows = remote.all(`SELECT * FROM "${table}"`);
1871
+ stat.first_sync = true;
1872
+ }
1873
+ } else {
1874
+ rows = remote.all(`SELECT * FROM "${table}"`);
1875
+ stat.first_sync = true;
1876
+ }
1877
+ for (let offset = 0;offset < rows.length; offset += batchSize) {
1878
+ const batch = rows.slice(offset, offset + batchSize);
1879
+ const result = transferRows(remote, local, table, batch, options);
1880
+ stat.synced_rows += result.written;
1881
+ stat.skipped_rows += result.skipped;
1882
+ stat.errors.push(...result.errors);
1883
+ }
1884
+ if (rows.length === 0) {
1885
+ stat.skipped_rows = stat.total_rows;
1886
+ }
1887
+ const now = new Date().toISOString();
1888
+ upsertSyncMeta(local, {
1889
+ table_name: table,
1890
+ last_synced_at: now,
1891
+ last_synced_row_count: stat.synced_rows,
1892
+ direction: "pull"
1893
+ });
1894
+ } catch (err) {
1895
+ stat.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
1896
+ }
1897
+ results.push(stat);
1898
+ }
1899
+ return results;
1900
+ }
1901
+ function getSyncMetaAll(db) {
1902
+ ensureSyncMetaTable(db);
1903
+ return db.all(`SELECT table_name, last_synced_at, last_synced_row_count, direction FROM _sync_meta ORDER BY table_name`);
1904
+ }
1905
+ function getSyncMetaForTable(db, table) {
1906
+ return getSyncMeta(db, table);
1907
+ }
1908
+ function resetSyncMeta(db, table) {
1909
+ ensureSyncMetaTable(db);
1910
+ db.run(`DELETE FROM _sync_meta WHERE table_name = ?`, table);
1911
+ }
1912
+ function resetAllSyncMeta(db) {
1913
+ ensureSyncMetaTable(db);
1914
+ db.run(`DELETE FROM _sync_meta`);
1915
+ }
1916
+ function getAutoSyncConfig() {
1917
+ try {
1918
+ if (!existsSync3(AUTO_SYNC_CONFIG_PATH)) {
1919
+ return { ...DEFAULT_AUTO_SYNC_CONFIG };
1920
+ }
1921
+ const raw = JSON.parse(readFileSync2(AUTO_SYNC_CONFIG_PATH, "utf-8"));
1922
+ return {
1923
+ auto_sync_on_start: typeof raw.auto_sync_on_start === "boolean" ? raw.auto_sync_on_start : DEFAULT_AUTO_SYNC_CONFIG.auto_sync_on_start,
1924
+ auto_sync_on_stop: typeof raw.auto_sync_on_stop === "boolean" ? raw.auto_sync_on_stop : DEFAULT_AUTO_SYNC_CONFIG.auto_sync_on_stop
1925
+ };
1926
+ } catch {
1927
+ return { ...DEFAULT_AUTO_SYNC_CONFIG };
1928
+ }
1929
+ }
1930
+ function executeAutoSync(event, local, remote, tables) {
1931
+ const direction = event === "start" ? "pull" : "push";
1932
+ const result = {
1933
+ event,
1934
+ direction,
1935
+ success: false,
1936
+ tables_synced: 0,
1937
+ total_rows_synced: 0,
1938
+ errors: []
1939
+ };
1940
+ try {
1941
+ const stats = direction === "pull" ? incrementalSyncPull(remote, local, tables) : incrementalSyncPush(local, remote, tables);
1942
+ for (const s of stats) {
1943
+ if (s.errors.length === 0) {
1944
+ result.tables_synced++;
1945
+ }
1946
+ result.total_rows_synced += s.synced_rows;
1947
+ result.errors.push(...s.errors);
1948
+ }
1949
+ result.success = result.errors.length === 0;
1950
+ } catch (err) {
1951
+ result.errors.push(err?.message ?? String(err));
1952
+ }
1953
+ return result;
1954
+ }
1955
+ function installSignalHandlers() {
1956
+ if (signalHandlersInstalled)
1957
+ return;
1958
+ signalHandlersInstalled = true;
1959
+ const handleExit = () => {
1960
+ for (const fn of cleanupHandlers) {
1961
+ try {
1962
+ fn();
1963
+ } catch {}
1964
+ }
1965
+ };
1966
+ process.on("SIGTERM", () => {
1967
+ handleExit();
1968
+ process.exit(0);
1969
+ });
1970
+ process.on("SIGINT", () => {
1971
+ handleExit();
1972
+ process.exit(0);
1973
+ });
1974
+ process.on("beforeExit", () => {
1975
+ handleExit();
1976
+ });
1977
+ }
1978
+ function setupAutoSync(serviceName, server, local, remote, tables) {
1979
+ const config = getAutoSyncConfig();
1980
+ const cloudConfig = getCloudConfig();
1981
+ const isSyncEnabled = cloudConfig.mode === "hybrid" || cloudConfig.mode === "cloud";
1982
+ const syncOnStart = () => {
1983
+ if (!config.auto_sync_on_start || !isSyncEnabled)
1984
+ return null;
1985
+ return executeAutoSync("start", local, remote, tables);
1986
+ };
1987
+ const syncOnStop = () => {
1988
+ if (!config.auto_sync_on_stop || !isSyncEnabled)
1989
+ return null;
1990
+ return executeAutoSync("stop", local, remote, tables);
1991
+ };
1992
+ if (server && typeof server.onconnect === "function") {
1993
+ const origOnConnect = server.onconnect;
1994
+ server.onconnect = (...args) => {
1995
+ syncOnStart();
1996
+ return origOnConnect.apply(server, args);
1997
+ };
1998
+ } else if (server && typeof server.on === "function") {
1999
+ server.on("connect", () => {
2000
+ syncOnStart();
2001
+ });
2002
+ }
2003
+ if (server && typeof server.ondisconnect === "function") {
2004
+ const origOnDisconnect = server.ondisconnect;
2005
+ server.ondisconnect = (...args) => {
2006
+ syncOnStop();
2007
+ return origOnDisconnect.apply(server, args);
2008
+ };
2009
+ } else if (server && typeof server.on === "function") {
2010
+ server.on("disconnect", () => {
2011
+ syncOnStop();
2012
+ });
2013
+ }
2014
+ installSignalHandlers();
2015
+ cleanupHandlers.push(() => {
2016
+ syncOnStop();
2017
+ });
2018
+ return { syncOnStart, syncOnStop, config };
2019
+ }
2020
+ function enableAutoSync(serviceName, mcpServer, local, remote, tables) {
2021
+ setupAutoSync(serviceName, mcpServer, local, remote, tables);
2022
+ }
2023
+ function discoverSyncableServices() {
2024
+ const hasnaDir = getHasnaDir();
2025
+ const services = [];
2026
+ try {
2027
+ const entries = readdirSync2(hasnaDir, { withFileTypes: true });
2028
+ for (const entry of entries) {
2029
+ if (!entry.isDirectory())
2030
+ continue;
2031
+ const dbPath = join4(hasnaDir, entry.name, `${entry.name}.db`);
2032
+ if (existsSync4(dbPath)) {
2033
+ services.push(entry.name);
2034
+ }
2035
+ }
2036
+ } catch {}
2037
+ return services;
2038
+ }
2039
+ async function runScheduledSync() {
2040
+ const config = getCloudConfig();
2041
+ if (config.mode === "local")
2042
+ return [];
2043
+ const services = discoverSyncableServices();
2044
+ const results = [];
2045
+ let remote = null;
2046
+ for (const service of services) {
2047
+ const result = {
2048
+ service,
2049
+ tables_synced: 0,
2050
+ total_rows_synced: 0,
2051
+ errors: []
2052
+ };
2053
+ try {
2054
+ const dbPath = join4(getDataDir(service), `${service}.db`);
2055
+ if (!existsSync4(dbPath)) {
2056
+ continue;
2057
+ }
2058
+ const local = new SqliteAdapter(dbPath);
2059
+ const tables = listSqliteTables(local).filter((t) => !t.startsWith("_") && !t.startsWith("sqlite_"));
2060
+ if (tables.length === 0) {
2061
+ local.close();
2062
+ continue;
2063
+ }
2064
+ try {
2065
+ const connStr = getConnectionString(service);
2066
+ remote = new PgAdapterAsync(connStr);
2067
+ } catch (err) {
2068
+ result.errors.push(`Connection failed: ${err?.message ?? String(err)}`);
2069
+ local.close();
2070
+ results.push(result);
2071
+ continue;
2072
+ }
2073
+ const stats = incrementalSyncPush(local, remote, tables);
2074
+ for (const s of stats) {
2075
+ if (s.errors.length === 0) {
2076
+ result.tables_synced++;
2077
+ }
2078
+ result.total_rows_synced += s.synced_rows;
2079
+ result.errors.push(...s.errors);
2080
+ }
2081
+ local.close();
2082
+ await remote.close();
2083
+ remote = null;
2084
+ } catch (err) {
2085
+ result.errors.push(err?.message ?? String(err));
2086
+ }
2087
+ results.push(result);
2088
+ }
2089
+ if (remote) {
2090
+ try {
2091
+ await remote.close();
2092
+ } catch {}
2093
+ }
2094
+ return results;
2095
+ }
2096
+ function getWorkerPath() {
2097
+ const dir = typeof import.meta.dir === "string" ? import.meta.dir : dirname(import.meta.url.replace("file://", ""));
2098
+ const tsPath = join5(dir, "scheduled-sync.ts");
2099
+ const jsPath = join5(dir, "scheduled-sync.js");
2100
+ try {
2101
+ const { existsSync: existsSync5 } = __require("fs");
2102
+ if (existsSync5(tsPath))
2103
+ return tsPath;
2104
+ } catch {}
2105
+ return jsPath;
2106
+ }
2107
+ function parseInterval(input) {
2108
+ const trimmed = input.trim().toLowerCase();
2109
+ const hourMatch = trimmed.match(/^(\d+)\s*h$/);
2110
+ if (hourMatch) {
2111
+ const hours = parseInt(hourMatch[1], 10);
2112
+ if (hours <= 0) {
2113
+ throw new Error(`Invalid interval "${input}". Value must be greater than 0.`);
2114
+ }
2115
+ return hours * 60;
2116
+ }
2117
+ const minMatch = trimmed.match(/^(\d+)\s*m$/);
2118
+ if (minMatch) {
2119
+ const mins = parseInt(minMatch[1], 10);
2120
+ if (mins <= 0) {
2121
+ throw new Error(`Invalid interval "${input}". Value must be greater than 0.`);
2122
+ }
2123
+ return mins;
2124
+ }
2125
+ const plain = parseInt(trimmed, 10);
2126
+ if (!isNaN(plain) && plain > 0) {
2127
+ return plain;
2128
+ }
2129
+ throw new Error(`Invalid interval "${input}". Use formats like: 5m, 10m, 1h, or a plain number of minutes.`);
2130
+ }
2131
+ function minutesToCron(minutes) {
2132
+ if (minutes <= 0) {
2133
+ throw new Error("Interval must be greater than 0 minutes.");
2134
+ }
2135
+ if (minutes < 60) {
2136
+ return `*/${minutes} * * * *`;
2137
+ }
2138
+ const hours = Math.floor(minutes / 60);
2139
+ const remainderMins = minutes % 60;
2140
+ if (remainderMins === 0 && hours <= 24) {
2141
+ return `0 */${hours} * * *`;
2142
+ }
2143
+ return `*/${minutes} * * * *`;
2144
+ }
2145
+ async function registerSyncSchedule(intervalMinutes) {
2146
+ if (intervalMinutes <= 0) {
2147
+ throw new Error("Interval must be a positive number of minutes.");
2148
+ }
2149
+ const cronExpr = minutesToCron(intervalMinutes);
2150
+ const workerPath = getWorkerPath();
2151
+ await Bun.cron(workerPath, cronExpr, CRON_TITLE);
2152
+ const config = getCloudConfig();
2153
+ config.sync.schedule_minutes = intervalMinutes;
2154
+ saveCloudConfig(config);
2155
+ }
2156
+ async function removeSyncSchedule() {
2157
+ await Bun.cron.remove(CRON_TITLE);
2158
+ const config = getCloudConfig();
2159
+ config.sync.schedule_minutes = 0;
2160
+ saveCloudConfig(config);
2161
+ }
2162
+ function getSyncScheduleStatus() {
2163
+ const config = getCloudConfig();
2164
+ const minutes = config.sync.schedule_minutes;
2165
+ const registered = minutes > 0;
2166
+ return {
2167
+ registered,
2168
+ schedule_minutes: minutes,
2169
+ cron_expression: registered ? minutesToCron(minutes) : null
2170
+ };
2171
+ }
2172
+ function registerCloudTools(server, serviceName) {
2173
+ server.tool(`${serviceName}_cloud_status`, "Show cloud configuration and connection health", {}, async () => {
2174
+ const config = getCloudConfig();
2175
+ const lines = [
2176
+ `Mode: ${config.mode}`,
2177
+ `Service: ${serviceName}`,
2178
+ `RDS Host: ${config.rds.host || "(not configured)"}`
2179
+ ];
2180
+ if (config.rds.host && config.rds.username) {
2181
+ try {
2182
+ const pg2 = new PgAdapterAsync(getConnectionString("postgres"));
2183
+ await pg2.get("SELECT 1 as ok");
2184
+ lines.push("PostgreSQL: connected");
2185
+ await pg2.close();
2186
+ } catch (err) {
2187
+ lines.push(`PostgreSQL: failed \u2014 ${err?.message}`);
2188
+ }
2189
+ }
2190
+ return { content: [{ type: "text", text: lines.join(`
2191
+ `) }] };
2192
+ });
2193
+ server.tool(`${serviceName}_cloud_push`, "Push local data to cloud PostgreSQL", {
2194
+ tables: exports_external2.string().optional().describe("Comma-separated table names (default: all)")
2195
+ }, async ({ tables: tablesStr }) => {
2196
+ const config = getCloudConfig();
2197
+ if (config.mode === "local") {
2198
+ return {
2199
+ content: [
2200
+ { type: "text", text: "Error: cloud mode not configured." }
2201
+ ],
2202
+ isError: true
2203
+ };
2204
+ }
2205
+ const local = new SqliteAdapter(getDbPath(serviceName));
2206
+ const cloud = new PgAdapterAsync(getConnectionString(serviceName));
2207
+ const tableList = tablesStr ? tablesStr.split(",").map((t) => t.trim()) : listSqliteTables(local);
2208
+ const results = await syncPush(local, cloud, { tables: tableList });
2209
+ local.close();
2210
+ await cloud.close();
2211
+ const total = results.reduce((s, r) => s + r.rowsWritten, 0);
2212
+ return {
2213
+ content: [{ type: "text", text: `Pushed ${total} rows across ${tableList.length} table(s).` }]
2214
+ };
2215
+ });
2216
+ server.tool(`${serviceName}_cloud_pull`, "Pull cloud PostgreSQL data to local", {
2217
+ tables: exports_external2.string().optional().describe("Comma-separated table names (default: all)")
2218
+ }, async ({ tables: tablesStr }) => {
2219
+ const config = getCloudConfig();
2220
+ if (config.mode === "local") {
2221
+ return {
2222
+ content: [
2223
+ { type: "text", text: "Error: cloud mode not configured." }
2224
+ ],
2225
+ isError: true
2226
+ };
2227
+ }
2228
+ const local = new SqliteAdapter(getDbPath(serviceName));
2229
+ const cloud = new PgAdapterAsync(getConnectionString(serviceName));
2230
+ let tableList;
2231
+ if (tablesStr) {
2232
+ tableList = tablesStr.split(",").map((t) => t.trim());
2233
+ } else {
2234
+ try {
2235
+ tableList = await listPgTables(cloud);
2236
+ } catch {
2237
+ local.close();
2238
+ await cloud.close();
2239
+ return {
2240
+ content: [
2241
+ { type: "text", text: "Error: failed to list cloud tables." }
2242
+ ],
2243
+ isError: true
2244
+ };
2245
+ }
2246
+ }
2247
+ const results = await syncPull(cloud, local, { tables: tableList });
2248
+ local.close();
2249
+ await cloud.close();
2250
+ const total = results.reduce((s, r) => s + r.rowsWritten, 0);
2251
+ return {
2252
+ content: [{ type: "text", text: `Pulled ${total} rows across ${tableList.length} table(s).` }]
2253
+ };
2254
+ });
2255
+ server.tool(`${serviceName}_cloud_feedback`, "Send feedback for this service", {
2256
+ message: exports_external2.string().describe("Feedback message"),
2257
+ email: exports_external2.string().optional().describe("Contact email")
2258
+ }, async ({ message, email }) => {
2259
+ const db = createDatabase({ service: "cloud" });
2260
+ const result = await sendFeedback({ service: serviceName, message, email }, db);
2261
+ db.close();
2262
+ return {
2263
+ content: [
2264
+ {
2265
+ type: "text",
2266
+ text: result.sent ? `Feedback sent (id: ${result.id})` : `Saved locally (id: ${result.id}): ${result.error}`
2267
+ }
2268
+ ]
2269
+ };
2270
+ });
2271
+ }
2272
+ function registerCloudCommands(program, serviceName) {
2273
+ const cloudCmd = program.command("cloud").description("Cloud sync and feedback commands");
2274
+ cloudCmd.command("status").description("Show cloud config and connection health").action(async () => {
2275
+ const config = getCloudConfig();
2276
+ console.log("Mode:", config.mode);
2277
+ console.log("RDS Host:", config.rds.host || "(not configured)");
2278
+ console.log("Service:", serviceName);
2279
+ if (config.rds.host && config.rds.username) {
2280
+ try {
2281
+ const connStr = getConnectionString("postgres");
2282
+ const pg2 = new PgAdapterAsync(connStr);
2283
+ await pg2.get("SELECT 1 as ok");
2284
+ console.log("PostgreSQL: connected");
2285
+ await pg2.close();
2286
+ } catch (err) {
2287
+ console.log("PostgreSQL: connection failed \u2014", err?.message);
2288
+ }
2289
+ }
2290
+ });
2291
+ cloudCmd.command("push").description("Push local data to cloud").option("--tables <tables>", "Comma-separated table names").action(async (opts) => {
2292
+ const config = getCloudConfig();
2293
+ if (config.mode === "local") {
2294
+ console.error("Error: mode is 'local'. Run `cloud setup` first.");
2295
+ process.exit(1);
2296
+ }
2297
+ const local = new SqliteAdapter(getDbPath(serviceName));
2298
+ const cloud = new PgAdapterAsync(getConnectionString(serviceName));
2299
+ const tables = opts.tables ? opts.tables.split(",").map((t) => t.trim()) : listSqliteTables(local);
2300
+ const results = await syncPush(local, cloud, {
2301
+ tables,
2302
+ onProgress: (p) => {
2303
+ if (p.phase === "done") {
2304
+ console.log(` ${p.table}: ${p.rowsWritten} rows pushed`);
2305
+ }
2306
+ }
2307
+ });
2308
+ local.close();
2309
+ await cloud.close();
2310
+ const total = results.reduce((s, r) => s + r.rowsWritten, 0);
2311
+ console.log(`Done. ${total} rows pushed.`);
2312
+ });
2313
+ cloudCmd.command("pull").description("Pull cloud data to local").option("--tables <tables>", "Comma-separated table names").action(async (opts) => {
2314
+ const config = getCloudConfig();
2315
+ if (config.mode === "local") {
2316
+ console.error("Error: mode is 'local'. Run `cloud setup` first.");
2317
+ process.exit(1);
2318
+ }
2319
+ const local = new SqliteAdapter(getDbPath(serviceName));
2320
+ const cloud = new PgAdapterAsync(getConnectionString(serviceName));
2321
+ let tables;
2322
+ if (opts.tables) {
2323
+ tables = opts.tables.split(",").map((t) => t.trim());
2324
+ } else {
2325
+ tables = await listPgTables(cloud);
2326
+ }
2327
+ const results = await syncPull(cloud, local, {
2328
+ tables,
2329
+ onProgress: (p) => {
2330
+ if (p.phase === "done") {
2331
+ console.log(` ${p.table}: ${p.rowsWritten} rows pulled`);
2332
+ }
2333
+ }
2334
+ });
2335
+ local.close();
2336
+ await cloud.close();
2337
+ const total = results.reduce((s, r) => s + r.rowsWritten, 0);
2338
+ console.log(`Done. ${total} rows pulled.`);
2339
+ });
2340
+ cloudCmd.command("feedback").description("Send feedback").requiredOption("--message <msg>", "Feedback message").option("--email <email>", "Contact email").action(async (opts) => {
2341
+ const db = createDatabase({ service: "cloud" });
2342
+ const result = await sendFeedback({ service: serviceName, message: opts.message, email: opts.email }, db);
2343
+ db.close();
2344
+ if (result.sent) {
2345
+ console.log(`Feedback sent (id: ${result.id})`);
2346
+ } else {
2347
+ console.log(`Feedback saved locally (id: ${result.id}): ${result.error}`);
2348
+ }
2349
+ });
1535
2350
  }
1536
2351
  var __create, __getProtoOf, __defProp2, __getOwnPropNames2, __hasOwnProp2, __toESMCache_node, __toESMCache_esm, __toESM = (mod, isNodeMode, target) => {
1537
2352
  var canCache = mod != null && typeof mod === "object";
@@ -1785,7 +2600,13 @@ CREATE TABLE IF NOT EXISTS feedback (
1785
2600
  email TEXT DEFAULT '',
1786
2601
  machine_id TEXT DEFAULT '',
1787
2602
  created_at TEXT DEFAULT (datetime('now'))
1788
- )`, AUTO_SYNC_CONFIG_PATH;
2603
+ )`, SYNC_META_TABLE_SQL = `
2604
+ CREATE TABLE IF NOT EXISTS _sync_meta (
2605
+ table_name TEXT PRIMARY KEY,
2606
+ last_synced_at TEXT,
2607
+ last_synced_row_count INTEGER DEFAULT 0,
2608
+ direction TEXT DEFAULT 'push'
2609
+ )`, AUTO_SYNC_CONFIG_PATH, DEFAULT_AUTO_SYNC_CONFIG, cleanupHandlers, signalHandlersInstalled = false, CRON_TITLE = "hasna-cloud-sync";
1789
2610
  var init_dist = __esm(() => {
1790
2611
  __create = Object.create;
1791
2612
  __getProtoOf = Object.getPrototypeOf;
@@ -9724,11 +10545,19 @@ See https://www.postgresql.org/docs/current/libpq-ssl.html for libpq SSL mode de
9724
10545
  }).default({}),
9725
10546
  mode: exports_external2.enum(["local", "cloud", "hybrid"]).default("local"),
9726
10547
  auto_sync_interval_minutes: exports_external2.number().default(0),
9727
- feedback_endpoint: exports_external2.string().default("https://feedback.hasna.com/api/v1/feedback")
10548
+ feedback_endpoint: exports_external2.string().default("https://feedback.hasna.com/api/v1/feedback"),
10549
+ sync: exports_external2.object({
10550
+ schedule_minutes: exports_external2.number().default(0)
10551
+ }).default({})
9728
10552
  });
9729
10553
  CONFIG_DIR = join2(homedir2(), ".hasna", "cloud");
9730
10554
  CONFIG_PATH = join2(CONFIG_DIR, "config.json");
9731
10555
  AUTO_SYNC_CONFIG_PATH = join3(homedir3(), ".hasna", "cloud", "config.json");
10556
+ DEFAULT_AUTO_SYNC_CONFIG = {
10557
+ auto_sync_on_start: true,
10558
+ auto_sync_on_stop: true
10559
+ };
10560
+ cleanupHandlers = [];
9732
10561
  });
9733
10562
 
9734
10563
  // src/types/index.ts
@@ -9851,18 +10680,18 @@ __export(exports_database, {
9851
10680
  LOCK_EXPIRY_MINUTES: () => LOCK_EXPIRY_MINUTES
9852
10681
  });
9853
10682
  import { Database as Database2 } from "bun:sqlite";
9854
- import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "fs";
9855
- import { dirname, join as join4, resolve } from "path";
10683
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
10684
+ import { dirname as dirname2, join as join6, resolve } from "path";
9856
10685
  function isInMemoryDb(path) {
9857
10686
  return path === ":memory:" || path.startsWith("file::memory:");
9858
10687
  }
9859
10688
  function findNearestTodosDb(startDir) {
9860
10689
  let dir = resolve(startDir);
9861
10690
  while (true) {
9862
- const candidate = join4(dir, ".todos", "todos.db");
9863
- if (existsSync3(candidate))
10691
+ const candidate = join6(dir, ".todos", "todos.db");
10692
+ if (existsSync5(candidate))
9864
10693
  return candidate;
9865
- const parent = dirname(dir);
10694
+ const parent = dirname2(dir);
9866
10695
  if (parent === dir)
9867
10696
  break;
9868
10697
  dir = parent;
@@ -9872,9 +10701,9 @@ function findNearestTodosDb(startDir) {
9872
10701
  function findGitRoot(startDir) {
9873
10702
  let dir = resolve(startDir);
9874
10703
  while (true) {
9875
- if (existsSync3(join4(dir, ".git")))
10704
+ if (existsSync5(join6(dir, ".git")))
9876
10705
  return dir;
9877
- const parent = dirname(dir);
10706
+ const parent = dirname2(dir);
9878
10707
  if (parent === dir)
9879
10708
  break;
9880
10709
  dir = parent;
@@ -9895,13 +10724,13 @@ function getDbPath2() {
9895
10724
  if (process.env["TODOS_DB_SCOPE"] === "project") {
9896
10725
  const gitRoot = findGitRoot(cwd);
9897
10726
  if (gitRoot) {
9898
- return join4(gitRoot, ".todos", "todos.db");
10727
+ return join6(gitRoot, ".todos", "todos.db");
9899
10728
  }
9900
10729
  }
9901
10730
  const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
9902
- const newPath = join4(home, ".hasna", "todos", "todos.db");
9903
- const legacyPath = join4(home, ".todos", "todos.db");
9904
- if (!existsSync3(newPath) && existsSync3(legacyPath)) {
10731
+ const newPath = join6(home, ".hasna", "todos", "todos.db");
10732
+ const legacyPath = join6(home, ".todos", "todos.db");
10733
+ if (!existsSync5(newPath) && existsSync5(legacyPath)) {
9905
10734
  return legacyPath;
9906
10735
  }
9907
10736
  return newPath;
@@ -9909,8 +10738,8 @@ function getDbPath2() {
9909
10738
  function ensureDir(filePath) {
9910
10739
  if (isInMemoryDb(filePath))
9911
10740
  return;
9912
- const dir = dirname(resolve(filePath));
9913
- if (!existsSync3(dir)) {
10741
+ const dir = dirname2(resolve(filePath));
10742
+ if (!existsSync5(dir)) {
9914
10743
  mkdirSync3(dir, { recursive: true });
9915
10744
  }
9916
10745
  }
@@ -10877,20 +11706,20 @@ var init_projects = __esm(() => {
10877
11706
  });
10878
11707
 
10879
11708
  // src/lib/sync-utils.ts
10880
- import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
10881
- import { join as join6 } from "path";
11709
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync3, readdirSync as readdirSync3, statSync, writeFileSync as writeFileSync2 } from "fs";
11710
+ import { join as join8 } from "path";
10882
11711
  function ensureDir2(dir) {
10883
- if (!existsSync5(dir))
11712
+ if (!existsSync7(dir))
10884
11713
  mkdirSync5(dir, { recursive: true });
10885
11714
  }
10886
11715
  function listJsonFiles(dir) {
10887
- if (!existsSync5(dir))
11716
+ if (!existsSync7(dir))
10888
11717
  return [];
10889
- return readdirSync2(dir).filter((f) => f.endsWith(".json"));
11718
+ return readdirSync3(dir).filter((f) => f.endsWith(".json"));
10890
11719
  }
10891
11720
  function readJsonFile(path) {
10892
11721
  try {
10893
- return JSON.parse(readFileSync2(path, "utf-8"));
11722
+ return JSON.parse(readFileSync3(path, "utf-8"));
10894
11723
  } catch {
10895
11724
  return null;
10896
11725
  }
@@ -10900,14 +11729,14 @@ function writeJsonFile(path, data) {
10900
11729
  `);
10901
11730
  }
10902
11731
  function readHighWaterMark(dir) {
10903
- const path = join6(dir, ".highwatermark");
10904
- if (!existsSync5(path))
11732
+ const path = join8(dir, ".highwatermark");
11733
+ if (!existsSync7(path))
10905
11734
  return 1;
10906
- const val = parseInt(readFileSync2(path, "utf-8").trim(), 10);
11735
+ const val = parseInt(readFileSync3(path, "utf-8").trim(), 10);
10907
11736
  return isNaN(val) ? 1 : val;
10908
11737
  }
10909
11738
  function writeHighWaterMark(dir, value) {
10910
- writeFileSync2(join6(dir, ".highwatermark"), String(value));
11739
+ writeFileSync2(join8(dir, ".highwatermark"), String(value));
10911
11740
  }
10912
11741
  function getFileMtimeMs(path) {
10913
11742
  try {
@@ -10933,18 +11762,18 @@ var init_sync_utils = __esm(() => {
10933
11762
  });
10934
11763
 
10935
11764
  // src/lib/config.ts
10936
- import { existsSync as existsSync6 } from "fs";
10937
- import { join as join7 } from "path";
11765
+ import { existsSync as existsSync8 } from "fs";
11766
+ import { join as join9 } from "path";
10938
11767
  function getTodosGlobalDir() {
10939
11768
  const home = process.env["HOME"] || HOME;
10940
- const newDir = join7(home, ".hasna", "todos");
10941
- const legacyDir = join7(home, ".todos");
10942
- if (!existsSync6(newDir) && existsSync6(legacyDir))
11769
+ const newDir = join9(home, ".hasna", "todos");
11770
+ const legacyDir = join9(home, ".todos");
11771
+ if (!existsSync8(newDir) && existsSync8(legacyDir))
10943
11772
  return legacyDir;
10944
11773
  return newDir;
10945
11774
  }
10946
- function getConfigPath() {
10947
- return join7(getTodosGlobalDir(), "config.json");
11775
+ function getConfigPath2() {
11776
+ return join9(getTodosGlobalDir(), "config.json");
10948
11777
  }
10949
11778
  function normalizeAgent(agent) {
10950
11779
  return agent.trim().toLowerCase();
@@ -10952,11 +11781,11 @@ function normalizeAgent(agent) {
10952
11781
  function loadConfig() {
10953
11782
  if (cached)
10954
11783
  return cached;
10955
- if (!existsSync6(getConfigPath())) {
11784
+ if (!existsSync8(getConfigPath2())) {
10956
11785
  cached = {};
10957
11786
  return cached;
10958
11787
  }
10959
- const config = readJsonFile(getConfigPath()) || {};
11788
+ const config = readJsonFile(getConfigPath2()) || {};
10960
11789
  if (typeof config.sync_agents === "string") {
10961
11790
  config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
10962
11791
  }
@@ -11268,6 +12097,7 @@ var init_webhooks = __esm(() => {
11268
12097
  // src/db/templates.ts
11269
12098
  var exports_templates = {};
11270
12099
  __export(exports_templates, {
12100
+ updateTemplate: () => updateTemplate,
11271
12101
  taskFromTemplate: () => taskFromTemplate,
11272
12102
  listTemplates: () => listTemplates,
11273
12103
  getTemplate: () => getTemplate,
@@ -11282,6 +12112,9 @@ function rowToTemplate(row) {
11282
12112
  priority: row.priority || "medium"
11283
12113
  };
11284
12114
  }
12115
+ function resolveTemplateId(id, d) {
12116
+ return resolvePartialId(d, "task_templates", id);
12117
+ }
11285
12118
  function createTemplate(input, db) {
11286
12119
  const d = db || getDatabase();
11287
12120
  const id = uuid();
@@ -11302,7 +12135,10 @@ function createTemplate(input, db) {
11302
12135
  }
11303
12136
  function getTemplate(id, db) {
11304
12137
  const d = db || getDatabase();
11305
- const row = d.query("SELECT * FROM task_templates WHERE id = ?").get(id);
12138
+ const resolved = resolveTemplateId(id, d);
12139
+ if (!resolved)
12140
+ return null;
12141
+ const row = d.query("SELECT * FROM task_templates WHERE id = ?").get(resolved);
11306
12142
  return row ? rowToTemplate(row) : null;
11307
12143
  }
11308
12144
  function listTemplates(db) {
@@ -11311,15 +12147,63 @@ function listTemplates(db) {
11311
12147
  }
11312
12148
  function deleteTemplate(id, db) {
11313
12149
  const d = db || getDatabase();
11314
- return d.run("DELETE FROM task_templates WHERE id = ?", [id]).changes > 0;
12150
+ const resolved = resolveTemplateId(id, d);
12151
+ if (!resolved)
12152
+ return false;
12153
+ return d.run("DELETE FROM task_templates WHERE id = ?", [resolved]).changes > 0;
11315
12154
  }
11316
- function taskFromTemplate(templateId, overrides = {}, db) {
11317
- const t = getTemplate(templateId, db);
11318
- if (!t)
11319
- throw new Error(`Template not found: ${templateId}`);
11320
- return {
11321
- title: overrides.title || t.title_pattern,
11322
- description: overrides.description ?? t.description ?? undefined,
12155
+ function updateTemplate(id, updates, db) {
12156
+ const d = db || getDatabase();
12157
+ const resolved = resolveTemplateId(id, d);
12158
+ if (!resolved)
12159
+ return null;
12160
+ const sets = [];
12161
+ const values = [];
12162
+ if (updates.name !== undefined) {
12163
+ sets.push("name = ?");
12164
+ values.push(updates.name);
12165
+ }
12166
+ if (updates.title_pattern !== undefined) {
12167
+ sets.push("title_pattern = ?");
12168
+ values.push(updates.title_pattern);
12169
+ }
12170
+ if (updates.description !== undefined) {
12171
+ sets.push("description = ?");
12172
+ values.push(updates.description);
12173
+ }
12174
+ if (updates.priority !== undefined) {
12175
+ sets.push("priority = ?");
12176
+ values.push(updates.priority);
12177
+ }
12178
+ if (updates.tags !== undefined) {
12179
+ sets.push("tags = ?");
12180
+ values.push(JSON.stringify(updates.tags));
12181
+ }
12182
+ if (updates.project_id !== undefined) {
12183
+ sets.push("project_id = ?");
12184
+ values.push(updates.project_id);
12185
+ }
12186
+ if (updates.plan_id !== undefined) {
12187
+ sets.push("plan_id = ?");
12188
+ values.push(updates.plan_id);
12189
+ }
12190
+ if (updates.metadata !== undefined) {
12191
+ sets.push("metadata = ?");
12192
+ values.push(JSON.stringify(updates.metadata));
12193
+ }
12194
+ if (sets.length === 0)
12195
+ return getTemplate(resolved, d);
12196
+ values.push(resolved);
12197
+ d.run(`UPDATE task_templates SET ${sets.join(", ")} WHERE id = ?`, values);
12198
+ return getTemplate(resolved, d);
12199
+ }
12200
+ function taskFromTemplate(templateId, overrides = {}, db) {
12201
+ const t = getTemplate(templateId, db);
12202
+ if (!t)
12203
+ throw new Error(`Template not found: ${templateId}`);
12204
+ return {
12205
+ title: overrides.title || t.title_pattern,
12206
+ description: overrides.description ?? t.description ?? undefined,
11323
12207
  priority: overrides.priority ?? t.priority,
11324
12208
  tags: overrides.tags ?? t.tags,
11325
12209
  project_id: overrides.project_id ?? t.project_id ?? undefined,
@@ -14358,210 +15242,795 @@ var init_agent_metrics = __esm(() => {
14358
15242
  init_database();
14359
15243
  });
14360
15244
 
14361
- // src/lib/extract.ts
14362
- var exports_extract = {};
14363
- __export(exports_extract, {
14364
- tagToPriority: () => tagToPriority,
14365
- extractTodos: () => extractTodos,
14366
- extractFromSource: () => extractFromSource,
14367
- EXTRACT_TAGS: () => EXTRACT_TAGS
15245
+ // src/lib/extract.ts
15246
+ var exports_extract = {};
15247
+ __export(exports_extract, {
15248
+ tagToPriority: () => tagToPriority,
15249
+ extractTodos: () => extractTodos,
15250
+ extractFromSource: () => extractFromSource,
15251
+ EXTRACT_TAGS: () => EXTRACT_TAGS
15252
+ });
15253
+ import { readFileSync as readFileSync6, statSync as statSync2 } from "fs";
15254
+ import { relative as relative2, resolve as resolve2, join as join12 } from "path";
15255
+ function tagToPriority(tag) {
15256
+ switch (tag) {
15257
+ case "BUG":
15258
+ case "FIXME":
15259
+ return "high";
15260
+ case "HACK":
15261
+ case "XXX":
15262
+ return "medium";
15263
+ case "TODO":
15264
+ return "medium";
15265
+ case "NOTE":
15266
+ return "low";
15267
+ }
15268
+ }
15269
+ function buildTagRegex(tags) {
15270
+ const tagPattern = tags.join("|");
15271
+ return new RegExp(`(?:^|\\s)(?:\\/\\/|\\/\\*|#|\\*|--|;;|%|<!--|\\{-)\\s*(?:@?)(${tagPattern})\\s*[:(]?\\s*(.*)$`, "i");
15272
+ }
15273
+ function extractFromSource(source, filePath, tags = [...EXTRACT_TAGS]) {
15274
+ const regex = buildTagRegex(tags);
15275
+ const results = [];
15276
+ const lines = source.split(`
15277
+ `);
15278
+ for (let i = 0;i < lines.length; i++) {
15279
+ const line = lines[i];
15280
+ const match = line.match(regex);
15281
+ if (match) {
15282
+ const tag = match[1].toUpperCase();
15283
+ let message = match[2].trim();
15284
+ message = message.replace(/\s*\*\/\s*$/, "").replace(/\s*-->\s*$/, "").replace(/\s*-\}\s*$/, "").trim();
15285
+ if (message) {
15286
+ results.push({
15287
+ tag,
15288
+ message,
15289
+ file: filePath,
15290
+ line: i + 1,
15291
+ raw: line
15292
+ });
15293
+ }
15294
+ }
15295
+ }
15296
+ return results;
15297
+ }
15298
+ function collectFiles(basePath, extensions) {
15299
+ const stat = statSync2(basePath);
15300
+ if (stat.isFile()) {
15301
+ return [basePath];
15302
+ }
15303
+ const glob = new Bun.Glob("**/*");
15304
+ const files = [];
15305
+ for (const entry of glob.scanSync({ cwd: basePath, onlyFiles: true, dot: false })) {
15306
+ const parts = entry.split("/");
15307
+ if (parts.some((p) => SKIP_DIRS.has(p)))
15308
+ continue;
15309
+ const dotIdx = entry.lastIndexOf(".");
15310
+ if (dotIdx === -1)
15311
+ continue;
15312
+ const ext = entry.slice(dotIdx);
15313
+ if (!extensions.has(ext))
15314
+ continue;
15315
+ files.push(entry);
15316
+ }
15317
+ return files.sort();
15318
+ }
15319
+ function extractTodos(options, db) {
15320
+ const basePath = resolve2(options.path);
15321
+ const tags = options.patterns || [...EXTRACT_TAGS];
15322
+ const extensions = options.extensions ? new Set(options.extensions.map((e) => e.startsWith(".") ? e : `.${e}`)) : DEFAULT_EXTENSIONS;
15323
+ const files = collectFiles(basePath, extensions);
15324
+ const allComments = [];
15325
+ for (const file of files) {
15326
+ const fullPath = statSync2(basePath).isFile() ? basePath : join12(basePath, file);
15327
+ try {
15328
+ const source = readFileSync6(fullPath, "utf-8");
15329
+ const relPath = statSync2(basePath).isFile() ? relative2(resolve2(basePath, ".."), fullPath) : file;
15330
+ const comments = extractFromSource(source, relPath, tags);
15331
+ allComments.push(...comments);
15332
+ } catch {}
15333
+ }
15334
+ if (options.dry_run) {
15335
+ return { comments: allComments, tasks: [], skipped: 0 };
15336
+ }
15337
+ const tasks = [];
15338
+ let skipped = 0;
15339
+ const existingTasks = options.project_id ? listTasks({ project_id: options.project_id, tags: ["extracted"] }, db) : listTasks({ tags: ["extracted"] }, db);
15340
+ const existingKeys = new Set;
15341
+ for (const t of existingTasks) {
15342
+ const meta = t.metadata;
15343
+ if (meta?.["source_file"] && meta?.["source_line"]) {
15344
+ existingKeys.add(`${meta["source_file"]}:${meta["source_line"]}`);
15345
+ }
15346
+ }
15347
+ for (const comment of allComments) {
15348
+ const dedupKey = `${comment.file}:${comment.line}`;
15349
+ if (existingKeys.has(dedupKey)) {
15350
+ skipped++;
15351
+ continue;
15352
+ }
15353
+ const taskTags = ["extracted", comment.tag.toLowerCase(), ...options.tags || []];
15354
+ const task = createTask({
15355
+ title: `[${comment.tag}] ${comment.message}`,
15356
+ description: `Extracted from code comment in \`${comment.file}\` at line ${comment.line}:
15357
+ \`\`\`
15358
+ ${comment.raw.trim()}
15359
+ \`\`\``,
15360
+ priority: tagToPriority(comment.tag),
15361
+ project_id: options.project_id,
15362
+ task_list_id: options.task_list_id,
15363
+ assigned_to: options.assigned_to,
15364
+ agent_id: options.agent_id,
15365
+ tags: taskTags,
15366
+ metadata: {
15367
+ source: "code_comment",
15368
+ comment_type: comment.tag,
15369
+ source_file: comment.file,
15370
+ source_line: comment.line
15371
+ }
15372
+ }, db);
15373
+ addTaskFile({
15374
+ task_id: task.id,
15375
+ path: comment.file,
15376
+ note: `Line ${comment.line}: ${comment.tag} comment`
15377
+ }, db);
15378
+ tasks.push(task);
15379
+ existingKeys.add(dedupKey);
15380
+ }
15381
+ return { comments: allComments, tasks, skipped };
15382
+ }
15383
+ var EXTRACT_TAGS, DEFAULT_EXTENSIONS, SKIP_DIRS;
15384
+ var init_extract = __esm(() => {
15385
+ init_tasks();
15386
+ init_task_files();
15387
+ EXTRACT_TAGS = ["TODO", "FIXME", "HACK", "XXX", "BUG", "NOTE"];
15388
+ DEFAULT_EXTENSIONS = new Set([
15389
+ ".ts",
15390
+ ".tsx",
15391
+ ".js",
15392
+ ".jsx",
15393
+ ".mjs",
15394
+ ".cjs",
15395
+ ".py",
15396
+ ".rb",
15397
+ ".go",
15398
+ ".rs",
15399
+ ".c",
15400
+ ".cpp",
15401
+ ".h",
15402
+ ".hpp",
15403
+ ".java",
15404
+ ".kt",
15405
+ ".swift",
15406
+ ".cs",
15407
+ ".php",
15408
+ ".sh",
15409
+ ".bash",
15410
+ ".zsh",
15411
+ ".lua",
15412
+ ".sql",
15413
+ ".r",
15414
+ ".R",
15415
+ ".yaml",
15416
+ ".yml",
15417
+ ".toml",
15418
+ ".css",
15419
+ ".scss",
15420
+ ".less",
15421
+ ".vue",
15422
+ ".svelte",
15423
+ ".ex",
15424
+ ".exs",
15425
+ ".erl",
15426
+ ".hs",
15427
+ ".ml",
15428
+ ".mli",
15429
+ ".clj",
15430
+ ".cljs"
15431
+ ]);
15432
+ SKIP_DIRS = new Set([
15433
+ "node_modules",
15434
+ ".git",
15435
+ "dist",
15436
+ "build",
15437
+ "out",
15438
+ ".next",
15439
+ ".turbo",
15440
+ "coverage",
15441
+ "__pycache__",
15442
+ ".venv",
15443
+ "venv",
15444
+ "vendor",
15445
+ "target",
15446
+ ".cache",
15447
+ ".parcel-cache"
15448
+ ]);
15449
+ });
15450
+
15451
+ // src/db/pg-migrations.ts
15452
+ var PG_MIGRATIONS;
15453
+ var init_pg_migrations = __esm(() => {
15454
+ PG_MIGRATIONS = [
15455
+ `
15456
+ CREATE TABLE IF NOT EXISTS projects (
15457
+ id TEXT PRIMARY KEY,
15458
+ name TEXT NOT NULL,
15459
+ path TEXT UNIQUE NOT NULL,
15460
+ description TEXT,
15461
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15462
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15463
+ );
15464
+
15465
+ CREATE TABLE IF NOT EXISTS tasks (
15466
+ id TEXT PRIMARY KEY,
15467
+ project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
15468
+ parent_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
15469
+ title TEXT NOT NULL,
15470
+ description TEXT,
15471
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'failed', 'cancelled')),
15472
+ priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high', 'critical')),
15473
+ agent_id TEXT,
15474
+ assigned_to TEXT,
15475
+ session_id TEXT,
15476
+ working_dir TEXT,
15477
+ tags TEXT DEFAULT '[]',
15478
+ metadata TEXT DEFAULT '{}',
15479
+ version INTEGER NOT NULL DEFAULT 1,
15480
+ locked_by TEXT,
15481
+ locked_at TEXT,
15482
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15483
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15484
+ completed_at TEXT
15485
+ );
15486
+
15487
+ CREATE TABLE IF NOT EXISTS task_dependencies (
15488
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15489
+ depends_on TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15490
+ PRIMARY KEY (task_id, depends_on),
15491
+ CHECK (task_id != depends_on)
15492
+ );
15493
+
15494
+ CREATE TABLE IF NOT EXISTS task_comments (
15495
+ id TEXT PRIMARY KEY,
15496
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15497
+ agent_id TEXT,
15498
+ session_id TEXT,
15499
+ content TEXT NOT NULL,
15500
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15501
+ );
15502
+
15503
+ CREATE TABLE IF NOT EXISTS sessions (
15504
+ id TEXT PRIMARY KEY,
15505
+ agent_id TEXT,
15506
+ project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
15507
+ working_dir TEXT,
15508
+ started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15509
+ last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15510
+ metadata TEXT DEFAULT '{}'
15511
+ );
15512
+
15513
+ CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
15514
+ CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
15515
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
15516
+ CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
15517
+ CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
15518
+ CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id);
15519
+ CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id);
15520
+ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id);
15521
+ CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id);
15522
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
15523
+
15524
+ CREATE TABLE IF NOT EXISTS _migrations (
15525
+ id INTEGER PRIMARY KEY,
15526
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15527
+ );
15528
+
15529
+ INSERT INTO _migrations (id) VALUES (1) ON CONFLICT DO NOTHING;
15530
+ `,
15531
+ `
15532
+ ALTER TABLE projects ADD COLUMN IF NOT EXISTS task_list_id TEXT;
15533
+ INSERT INTO _migrations (id) VALUES (2) ON CONFLICT DO NOTHING;
15534
+ `,
15535
+ `
15536
+ CREATE TABLE IF NOT EXISTS task_tags (
15537
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15538
+ tag TEXT NOT NULL,
15539
+ PRIMARY KEY (task_id, tag)
15540
+ );
15541
+ CREATE INDEX IF NOT EXISTS idx_task_tags_tag ON task_tags(tag);
15542
+ CREATE INDEX IF NOT EXISTS idx_task_tags_task ON task_tags(task_id);
15543
+
15544
+ INSERT INTO _migrations (id) VALUES (3) ON CONFLICT DO NOTHING;
15545
+ `,
15546
+ `
15547
+ CREATE TABLE IF NOT EXISTS plans (
15548
+ id TEXT PRIMARY KEY,
15549
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
15550
+ name TEXT NOT NULL,
15551
+ description TEXT,
15552
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
15553
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15554
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15555
+ );
15556
+ CREATE INDEX IF NOT EXISTS idx_plans_project ON plans(project_id);
15557
+ CREATE INDEX IF NOT EXISTS idx_plans_status ON plans(status);
15558
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL;
15559
+ CREATE INDEX IF NOT EXISTS idx_tasks_plan ON tasks(plan_id);
15560
+ INSERT INTO _migrations (id) VALUES (4) ON CONFLICT DO NOTHING;
15561
+ `,
15562
+ `
15563
+ CREATE TABLE IF NOT EXISTS agents (
15564
+ id TEXT PRIMARY KEY,
15565
+ name TEXT NOT NULL UNIQUE,
15566
+ description TEXT,
15567
+ metadata TEXT DEFAULT '{}',
15568
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15569
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15570
+ );
15571
+ CREATE INDEX IF NOT EXISTS idx_agents_name ON agents(name);
15572
+
15573
+ CREATE TABLE IF NOT EXISTS task_lists (
15574
+ id TEXT PRIMARY KEY,
15575
+ project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
15576
+ slug TEXT NOT NULL,
15577
+ name TEXT NOT NULL,
15578
+ description TEXT,
15579
+ metadata TEXT DEFAULT '{}',
15580
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15581
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15582
+ UNIQUE(project_id, slug)
15583
+ );
15584
+ CREATE INDEX IF NOT EXISTS idx_task_lists_project ON task_lists(project_id);
15585
+ CREATE INDEX IF NOT EXISTS idx_task_lists_slug ON task_lists(slug);
15586
+
15587
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS task_list_id TEXT REFERENCES task_lists(id) ON DELETE SET NULL;
15588
+ CREATE INDEX IF NOT EXISTS idx_tasks_task_list ON tasks(task_list_id);
15589
+
15590
+ INSERT INTO _migrations (id) VALUES (5) ON CONFLICT DO NOTHING;
15591
+ `,
15592
+ `
15593
+ ALTER TABLE projects ADD COLUMN IF NOT EXISTS task_prefix TEXT;
15594
+ ALTER TABLE projects ADD COLUMN IF NOT EXISTS task_counter INTEGER NOT NULL DEFAULT 0;
15595
+
15596
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS short_id TEXT;
15597
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_short_id ON tasks(short_id) WHERE short_id IS NOT NULL;
15598
+
15599
+ INSERT INTO _migrations (id) VALUES (6) ON CONFLICT DO NOTHING;
15600
+ `,
15601
+ `
15602
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS due_at TEXT;
15603
+ CREATE INDEX IF NOT EXISTS idx_tasks_due_at ON tasks(due_at);
15604
+ INSERT INTO _migrations (id) VALUES (7) ON CONFLICT DO NOTHING;
15605
+ `,
15606
+ `
15607
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'agent';
15608
+ INSERT INTO _migrations (id) VALUES (8) ON CONFLICT DO NOTHING;
15609
+ `,
15610
+ `
15611
+ ALTER TABLE plans ADD COLUMN IF NOT EXISTS task_list_id TEXT REFERENCES task_lists(id) ON DELETE SET NULL;
15612
+ ALTER TABLE plans ADD COLUMN IF NOT EXISTS agent_id TEXT;
15613
+ CREATE INDEX IF NOT EXISTS idx_plans_task_list ON plans(task_list_id);
15614
+ CREATE INDEX IF NOT EXISTS idx_plans_agent ON plans(agent_id);
15615
+ INSERT INTO _migrations (id) VALUES (9) ON CONFLICT DO NOTHING;
15616
+ `,
15617
+ `
15618
+ CREATE TABLE IF NOT EXISTS task_history (
15619
+ id TEXT PRIMARY KEY,
15620
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15621
+ action TEXT NOT NULL,
15622
+ field TEXT,
15623
+ old_value TEXT,
15624
+ new_value TEXT,
15625
+ agent_id TEXT,
15626
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15627
+ );
15628
+ CREATE INDEX IF NOT EXISTS idx_task_history_task ON task_history(task_id);
15629
+ CREATE INDEX IF NOT EXISTS idx_task_history_agent ON task_history(agent_id);
15630
+
15631
+ CREATE TABLE IF NOT EXISTS webhooks (
15632
+ id TEXT PRIMARY KEY,
15633
+ url TEXT NOT NULL,
15634
+ events TEXT NOT NULL DEFAULT '[]',
15635
+ secret TEXT,
15636
+ active BOOLEAN NOT NULL DEFAULT TRUE,
15637
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15638
+ );
15639
+
15640
+ CREATE TABLE IF NOT EXISTS task_templates (
15641
+ id TEXT PRIMARY KEY,
15642
+ name TEXT NOT NULL,
15643
+ title_pattern TEXT NOT NULL,
15644
+ description TEXT,
15645
+ priority TEXT DEFAULT 'medium',
15646
+ tags TEXT DEFAULT '[]',
15647
+ project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
15648
+ plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
15649
+ metadata TEXT DEFAULT '{}',
15650
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15651
+ );
15652
+
15653
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS estimated_minutes INTEGER;
15654
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN NOT NULL DEFAULT FALSE;
15655
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS approved_by TEXT;
15656
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS approved_at TEXT;
15657
+
15658
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS permissions TEXT DEFAULT '["*"]';
15659
+
15660
+ INSERT INTO _migrations (id) VALUES (10) ON CONFLICT DO NOTHING;
15661
+ `,
15662
+ `
15663
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS reports_to TEXT;
15664
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS title TEXT;
15665
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS level TEXT;
15666
+ INSERT INTO _migrations (id) VALUES (11) ON CONFLICT DO NOTHING;
15667
+ `,
15668
+ `
15669
+ CREATE TABLE IF NOT EXISTS orgs (
15670
+ id TEXT PRIMARY KEY,
15671
+ name TEXT NOT NULL UNIQUE,
15672
+ description TEXT,
15673
+ metadata TEXT DEFAULT '{}',
15674
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15675
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15676
+ );
15677
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS org_id TEXT REFERENCES orgs(id) ON DELETE SET NULL;
15678
+ ALTER TABLE projects ADD COLUMN IF NOT EXISTS org_id TEXT REFERENCES orgs(id) ON DELETE SET NULL;
15679
+ INSERT INTO _migrations (id) VALUES (12) ON CONFLICT DO NOTHING;
15680
+ `,
15681
+ `
15682
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_rule TEXT;
15683
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_parent_id TEXT REFERENCES tasks(id) ON DELETE SET NULL;
15684
+ CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_parent ON tasks(recurrence_parent_id);
15685
+ CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_rule ON tasks(recurrence_rule) WHERE recurrence_rule IS NOT NULL;
15686
+ INSERT INTO _migrations (id) VALUES (13) ON CONFLICT DO NOTHING;
15687
+ `,
15688
+ `
15689
+ ALTER TABLE task_comments ADD COLUMN IF NOT EXISTS type TEXT DEFAULT 'comment' CHECK(type IN ('comment', 'progress', 'note'));
15690
+ ALTER TABLE task_comments ADD COLUMN IF NOT EXISTS progress_pct INTEGER CHECK(progress_pct IS NULL OR (progress_pct >= 0 AND progress_pct <= 100));
15691
+ INSERT INTO _migrations (id) VALUES (14) ON CONFLICT DO NOTHING;
15692
+ `,
15693
+ `
15694
+ -- PostgreSQL uses tsvector/tsquery instead of FTS5
15695
+ -- Full-text search can be done with to_tsvector/to_tsquery on tasks table directly
15696
+ -- No virtual table needed; add a generated tsvector column instead
15697
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS search_vector tsvector;
15698
+
15699
+ CREATE INDEX IF NOT EXISTS idx_tasks_search ON tasks USING GIN(search_vector);
15700
+
15701
+ -- Function to update search vector
15702
+ CREATE OR REPLACE FUNCTION tasks_search_vector_update() RETURNS trigger AS $$
15703
+ BEGIN
15704
+ NEW.search_vector :=
15705
+ setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
15706
+ setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
15707
+ setweight(to_tsvector('english', COALESCE(NEW.tags, '')), 'C');
15708
+ RETURN NEW;
15709
+ END;
15710
+ $$ LANGUAGE plpgsql;
15711
+
15712
+ DROP TRIGGER IF EXISTS tasks_search_vector_trigger ON tasks;
15713
+ CREATE TRIGGER tasks_search_vector_trigger
15714
+ BEFORE INSERT OR UPDATE OF title, description, tags ON tasks
15715
+ FOR EACH ROW EXECUTE FUNCTION tasks_search_vector_update();
15716
+
15717
+ -- Backfill existing rows
15718
+ UPDATE tasks SET search_vector =
15719
+ setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
15720
+ setweight(to_tsvector('english', COALESCE(description, '')), 'B') ||
15721
+ setweight(to_tsvector('english', COALESCE(tags, '')), 'C')
15722
+ WHERE search_vector IS NULL;
15723
+
15724
+ INSERT INTO _migrations (id) VALUES (15) ON CONFLICT DO NOTHING;
15725
+ `,
15726
+ `
15727
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS spawns_template_id TEXT REFERENCES task_templates(id) ON DELETE SET NULL;
15728
+ INSERT INTO _migrations (id) VALUES (16) ON CONFLICT DO NOTHING;
15729
+ `,
15730
+ `
15731
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS session_id TEXT;
15732
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS working_dir TEXT;
15733
+ INSERT INTO _migrations (id) VALUES (17) ON CONFLICT DO NOTHING;
15734
+ `,
15735
+ `
15736
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS confidence DOUBLE PRECISION;
15737
+ INSERT INTO _migrations (id) VALUES (18) ON CONFLICT DO NOTHING;
15738
+ `,
15739
+ `
15740
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS reason TEXT;
15741
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS spawned_from_session TEXT;
15742
+ INSERT INTO _migrations (id) VALUES (19) ON CONFLICT DO NOTHING;
15743
+ `,
15744
+ `
15745
+ CREATE TABLE IF NOT EXISTS handoffs (
15746
+ id TEXT PRIMARY KEY,
15747
+ agent_id TEXT,
15748
+ project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
15749
+ summary TEXT NOT NULL,
15750
+ completed TEXT,
15751
+ in_progress TEXT,
15752
+ blockers TEXT,
15753
+ next_steps TEXT,
15754
+ created_at TIMESTAMPTZ NOT NULL
15755
+ );
15756
+ INSERT INTO _migrations (id) VALUES (20) ON CONFLICT DO NOTHING;
15757
+ `,
15758
+ `
15759
+ CREATE TABLE IF NOT EXISTS task_checklists (
15760
+ id TEXT PRIMARY KEY,
15761
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15762
+ position INTEGER NOT NULL DEFAULT 0,
15763
+ text TEXT NOT NULL,
15764
+ checked BOOLEAN NOT NULL DEFAULT FALSE,
15765
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15766
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15767
+ );
15768
+ CREATE INDEX IF NOT EXISTS idx_task_checklists_task ON task_checklists(task_id);
15769
+ INSERT INTO _migrations (id) VALUES (21) ON CONFLICT DO NOTHING;
15770
+ `,
15771
+ `
15772
+ CREATE TABLE IF NOT EXISTS project_sources (
15773
+ id TEXT PRIMARY KEY,
15774
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
15775
+ type TEXT NOT NULL,
15776
+ name TEXT NOT NULL,
15777
+ uri TEXT NOT NULL,
15778
+ description TEXT,
15779
+ metadata TEXT DEFAULT '{}',
15780
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15781
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15782
+ );
15783
+ CREATE INDEX IF NOT EXISTS idx_project_sources_project ON project_sources(project_id);
15784
+ CREATE INDEX IF NOT EXISTS idx_project_sources_type ON project_sources(type);
15785
+ INSERT INTO _migrations (id) VALUES (22) ON CONFLICT DO NOTHING;
15786
+ `,
15787
+ `
15788
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS active_project_id TEXT;
15789
+ INSERT INTO _migrations (id) VALUES (23) ON CONFLICT DO NOTHING;
15790
+ `,
15791
+ `
15792
+ CREATE TABLE IF NOT EXISTS resource_locks (
15793
+ resource_type TEXT NOT NULL,
15794
+ resource_id TEXT NOT NULL,
15795
+ agent_id TEXT NOT NULL,
15796
+ lock_type TEXT NOT NULL DEFAULT 'advisory',
15797
+ locked_at TIMESTAMPTZ NOT NULL,
15798
+ expires_at TIMESTAMPTZ NOT NULL,
15799
+ UNIQUE(resource_type, resource_id, lock_type)
15800
+ );
15801
+ CREATE INDEX IF NOT EXISTS idx_resource_locks_type_id ON resource_locks(resource_type, resource_id);
15802
+ CREATE INDEX IF NOT EXISTS idx_resource_locks_agent ON resource_locks(agent_id);
15803
+ INSERT INTO _migrations (id) VALUES (24) ON CONFLICT DO NOTHING;
15804
+ `,
15805
+ `
15806
+ CREATE TABLE IF NOT EXISTS task_files (
15807
+ id TEXT PRIMARY KEY,
15808
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15809
+ path TEXT NOT NULL,
15810
+ status TEXT NOT NULL DEFAULT 'active',
15811
+ agent_id TEXT,
15812
+ note TEXT,
15813
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15814
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15815
+ );
15816
+ CREATE INDEX IF NOT EXISTS idx_task_files_task ON task_files(task_id);
15817
+ CREATE INDEX IF NOT EXISTS idx_task_files_path ON task_files(path);
15818
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_files_task_path ON task_files(task_id, path);
15819
+ INSERT INTO _migrations (id) VALUES (25) ON CONFLICT DO NOTHING;
15820
+ `,
15821
+ `
15822
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assigned_by TEXT;
15823
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assigned_from_project TEXT;
15824
+ CREATE INDEX IF NOT EXISTS idx_tasks_assigned_by ON tasks(assigned_by);
15825
+ INSERT INTO _migrations (id) VALUES (26) ON CONFLICT DO NOTHING;
15826
+ `,
15827
+ `
15828
+ CREATE TABLE IF NOT EXISTS task_relationships (
15829
+ id TEXT PRIMARY KEY,
15830
+ source_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15831
+ target_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15832
+ relationship_type TEXT NOT NULL CHECK(relationship_type IN ('related_to', 'conflicts_with', 'similar_to', 'duplicates', 'supersedes', 'modifies_same_file')),
15833
+ metadata TEXT DEFAULT '{}',
15834
+ created_by TEXT,
15835
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15836
+ CHECK (source_task_id != target_task_id)
15837
+ );
15838
+ CREATE INDEX IF NOT EXISTS idx_task_rel_source ON task_relationships(source_task_id);
15839
+ CREATE INDEX IF NOT EXISTS idx_task_rel_target ON task_relationships(target_task_id);
15840
+ CREATE INDEX IF NOT EXISTS idx_task_rel_type ON task_relationships(relationship_type);
15841
+ INSERT INTO _migrations (id) VALUES (27) ON CONFLICT DO NOTHING;
15842
+ `,
15843
+ `
15844
+ CREATE TABLE IF NOT EXISTS kg_edges (
15845
+ id TEXT PRIMARY KEY,
15846
+ source_id TEXT NOT NULL,
15847
+ source_type TEXT NOT NULL,
15848
+ target_id TEXT NOT NULL,
15849
+ target_type TEXT NOT NULL,
15850
+ relation_type TEXT NOT NULL,
15851
+ weight DOUBLE PRECISION NOT NULL DEFAULT 1.0,
15852
+ metadata TEXT DEFAULT '{}',
15853
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15854
+ UNIQUE(source_id, source_type, target_id, target_type, relation_type)
15855
+ );
15856
+ CREATE INDEX IF NOT EXISTS idx_kg_source ON kg_edges(source_id, source_type);
15857
+ CREATE INDEX IF NOT EXISTS idx_kg_target ON kg_edges(target_id, target_type);
15858
+ CREATE INDEX IF NOT EXISTS idx_kg_relation ON kg_edges(relation_type);
15859
+ INSERT INTO _migrations (id) VALUES (28) ON CONFLICT DO NOTHING;
15860
+ `,
15861
+ `
15862
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS capabilities TEXT DEFAULT '[]';
15863
+ INSERT INTO _migrations (id) VALUES (29) ON CONFLICT DO NOTHING;
15864
+ `,
15865
+ `
15866
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived'));
15867
+ CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
15868
+ INSERT INTO _migrations (id) VALUES (30) ON CONFLICT DO NOTHING;
15869
+ `,
15870
+ `
15871
+ CREATE TABLE IF NOT EXISTS project_agent_roles (
15872
+ id TEXT PRIMARY KEY,
15873
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
15874
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
15875
+ role TEXT NOT NULL,
15876
+ is_lead BOOLEAN NOT NULL DEFAULT FALSE,
15877
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15878
+ UNIQUE(project_id, agent_id, role)
15879
+ );
15880
+ CREATE INDEX IF NOT EXISTS idx_project_agent_roles_project ON project_agent_roles(project_id);
15881
+ CREATE INDEX IF NOT EXISTS idx_project_agent_roles_agent ON project_agent_roles(agent_id);
15882
+ INSERT INTO _migrations (id) VALUES (31) ON CONFLICT DO NOTHING;
15883
+ `,
15884
+ `
15885
+ CREATE TABLE IF NOT EXISTS task_commits (
15886
+ id TEXT PRIMARY KEY,
15887
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15888
+ sha TEXT NOT NULL,
15889
+ message TEXT,
15890
+ author TEXT,
15891
+ files_changed TEXT,
15892
+ committed_at TEXT,
15893
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15894
+ UNIQUE(task_id, sha)
15895
+ );
15896
+ CREATE INDEX IF NOT EXISTS idx_task_commits_task ON task_commits(task_id);
15897
+ CREATE INDEX IF NOT EXISTS idx_task_commits_sha ON task_commits(sha);
15898
+ INSERT INTO _migrations (id) VALUES (32) ON CONFLICT DO NOTHING;
15899
+ `,
15900
+ `
15901
+ CREATE TABLE IF NOT EXISTS file_locks (
15902
+ id TEXT PRIMARY KEY,
15903
+ path TEXT NOT NULL UNIQUE,
15904
+ agent_id TEXT NOT NULL,
15905
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
15906
+ expires_at TIMESTAMPTZ NOT NULL,
15907
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15908
+ );
15909
+ CREATE INDEX IF NOT EXISTS idx_file_locks_path ON file_locks(path);
15910
+ CREATE INDEX IF NOT EXISTS idx_file_locks_agent ON file_locks(agent_id);
15911
+ CREATE INDEX IF NOT EXISTS idx_file_locks_expires ON file_locks(expires_at);
15912
+ INSERT INTO _migrations (id) VALUES (33) ON CONFLICT DO NOTHING;
15913
+ `,
15914
+ `
15915
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS started_at TEXT;
15916
+ INSERT INTO _migrations (id) VALUES (34) ON CONFLICT DO NOTHING;
15917
+ `,
15918
+ `
15919
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS task_type TEXT;
15920
+ CREATE INDEX IF NOT EXISTS idx_tasks_task_type ON tasks(task_type);
15921
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS cost_tokens INTEGER DEFAULT 0;
15922
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS cost_usd DOUBLE PRECISION DEFAULT 0;
15923
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS delegated_from TEXT;
15924
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS delegation_depth INTEGER DEFAULT 0;
15925
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS retry_count INTEGER DEFAULT 0;
15926
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS max_retries INTEGER DEFAULT 3;
15927
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS retry_after TEXT;
15928
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS sla_minutes INTEGER;
15929
+
15930
+ CREATE TABLE IF NOT EXISTS task_traces (
15931
+ id TEXT PRIMARY KEY,
15932
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
15933
+ agent_id TEXT,
15934
+ trace_type TEXT NOT NULL CHECK(trace_type IN ('tool_call','llm_call','error','handoff','custom')),
15935
+ name TEXT,
15936
+ input_summary TEXT,
15937
+ output_summary TEXT,
15938
+ duration_ms INTEGER,
15939
+ tokens INTEGER,
15940
+ cost_usd DOUBLE PRECISION,
15941
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15942
+ );
15943
+ CREATE INDEX IF NOT EXISTS idx_task_traces_task ON task_traces(task_id);
15944
+ CREATE INDEX IF NOT EXISTS idx_task_traces_agent ON task_traces(agent_id);
15945
+
15946
+ CREATE TABLE IF NOT EXISTS context_snapshots (
15947
+ id TEXT PRIMARY KEY,
15948
+ agent_id TEXT,
15949
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
15950
+ project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
15951
+ snapshot_type TEXT NOT NULL CHECK(snapshot_type IN ('interrupt','complete','handoff','checkpoint')),
15952
+ plan_summary TEXT,
15953
+ files_open TEXT DEFAULT '[]',
15954
+ attempts TEXT DEFAULT '[]',
15955
+ blockers TEXT DEFAULT '[]',
15956
+ next_steps TEXT,
15957
+ metadata TEXT DEFAULT '{}',
15958
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15959
+ );
15960
+ CREATE INDEX IF NOT EXISTS idx_snapshots_agent ON context_snapshots(agent_id);
15961
+ CREATE INDEX IF NOT EXISTS idx_snapshots_task ON context_snapshots(task_id);
15962
+
15963
+ CREATE TABLE IF NOT EXISTS agent_budgets (
15964
+ agent_id TEXT PRIMARY KEY,
15965
+ max_concurrent INTEGER DEFAULT 5,
15966
+ max_cost_usd DOUBLE PRECISION,
15967
+ max_task_minutes INTEGER,
15968
+ period_hours INTEGER DEFAULT 24,
15969
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15970
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15971
+ );
15972
+
15973
+ INSERT INTO _migrations (id) VALUES (35) ON CONFLICT DO NOTHING;
15974
+ `,
15975
+ `
15976
+ CREATE TABLE IF NOT EXISTS feedback (
15977
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
15978
+ message TEXT NOT NULL,
15979
+ email TEXT,
15980
+ category TEXT DEFAULT 'general',
15981
+ version TEXT,
15982
+ machine_id TEXT,
15983
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15984
+ );
15985
+
15986
+ INSERT INTO _migrations (id) VALUES (36) ON CONFLICT DO NOTHING;
15987
+ `
15988
+ ];
15989
+ });
15990
+
15991
+ // src/db/pg-migrate.ts
15992
+ var exports_pg_migrate = {};
15993
+ __export(exports_pg_migrate, {
15994
+ applyPgMigrations: () => applyPgMigrations
14368
15995
  });
14369
- import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
14370
- import { relative as relative2, resolve as resolve2, join as join10 } from "path";
14371
- function tagToPriority(tag) {
14372
- switch (tag) {
14373
- case "BUG":
14374
- case "FIXME":
14375
- return "high";
14376
- case "HACK":
14377
- case "XXX":
14378
- return "medium";
14379
- case "TODO":
14380
- return "medium";
14381
- case "NOTE":
14382
- return "low";
14383
- }
14384
- }
14385
- function buildTagRegex(tags) {
14386
- const tagPattern = tags.join("|");
14387
- return new RegExp(`(?:^|\\s)(?:\\/\\/|\\/\\*|#|\\*|--|;;|%|<!--|\\{-)\\s*(?:@?)(${tagPattern})\\s*[:(]?\\s*(.*)$`, "i");
14388
- }
14389
- function extractFromSource(source, filePath, tags = [...EXTRACT_TAGS]) {
14390
- const regex = buildTagRegex(tags);
14391
- const results = [];
14392
- const lines = source.split(`
14393
- `);
14394
- for (let i = 0;i < lines.length; i++) {
14395
- const line = lines[i];
14396
- const match = line.match(regex);
14397
- if (match) {
14398
- const tag = match[1].toUpperCase();
14399
- let message = match[2].trim();
14400
- message = message.replace(/\s*\*\/\s*$/, "").replace(/\s*-->\s*$/, "").replace(/\s*-\}\s*$/, "").trim();
14401
- if (message) {
14402
- results.push({
14403
- tag,
14404
- message,
14405
- file: filePath,
14406
- line: i + 1,
14407
- raw: line
14408
- });
15996
+ async function applyPgMigrations(connectionString) {
15997
+ const pg = new PgAdapterAsync(connectionString);
15998
+ const result = {
15999
+ applied: [],
16000
+ alreadyApplied: [],
16001
+ errors: [],
16002
+ totalMigrations: PG_MIGRATIONS.length
16003
+ };
16004
+ try {
16005
+ await pg.run(`CREATE TABLE IF NOT EXISTS _pg_migrations (
16006
+ id SERIAL PRIMARY KEY,
16007
+ version INT UNIQUE NOT NULL,
16008
+ applied_at TIMESTAMPTZ DEFAULT NOW()
16009
+ )`);
16010
+ const applied = await pg.all("SELECT version FROM _pg_migrations ORDER BY version");
16011
+ const appliedSet = new Set(applied.map((r) => r.version));
16012
+ for (let i = 0;i < PG_MIGRATIONS.length; i++) {
16013
+ if (appliedSet.has(i)) {
16014
+ result.alreadyApplied.push(i);
16015
+ continue;
14409
16016
  }
14410
- }
14411
- }
14412
- return results;
14413
- }
14414
- function collectFiles(basePath, extensions) {
14415
- const stat = statSync2(basePath);
14416
- if (stat.isFile()) {
14417
- return [basePath];
14418
- }
14419
- const glob = new Bun.Glob("**/*");
14420
- const files = [];
14421
- for (const entry of glob.scanSync({ cwd: basePath, onlyFiles: true, dot: false })) {
14422
- const parts = entry.split("/");
14423
- if (parts.some((p) => SKIP_DIRS.has(p)))
14424
- continue;
14425
- const dotIdx = entry.lastIndexOf(".");
14426
- if (dotIdx === -1)
14427
- continue;
14428
- const ext = entry.slice(dotIdx);
14429
- if (!extensions.has(ext))
14430
- continue;
14431
- files.push(entry);
14432
- }
14433
- return files.sort();
14434
- }
14435
- function extractTodos(options, db) {
14436
- const basePath = resolve2(options.path);
14437
- const tags = options.patterns || [...EXTRACT_TAGS];
14438
- const extensions = options.extensions ? new Set(options.extensions.map((e) => e.startsWith(".") ? e : `.${e}`)) : DEFAULT_EXTENSIONS;
14439
- const files = collectFiles(basePath, extensions);
14440
- const allComments = [];
14441
- for (const file of files) {
14442
- const fullPath = statSync2(basePath).isFile() ? basePath : join10(basePath, file);
14443
- try {
14444
- const source = readFileSync4(fullPath, "utf-8");
14445
- const relPath = statSync2(basePath).isFile() ? relative2(resolve2(basePath, ".."), fullPath) : file;
14446
- const comments = extractFromSource(source, relPath, tags);
14447
- allComments.push(...comments);
14448
- } catch {}
14449
- }
14450
- if (options.dry_run) {
14451
- return { comments: allComments, tasks: [], skipped: 0 };
14452
- }
14453
- const tasks = [];
14454
- let skipped = 0;
14455
- const existingTasks = options.project_id ? listTasks({ project_id: options.project_id, tags: ["extracted"] }, db) : listTasks({ tags: ["extracted"] }, db);
14456
- const existingKeys = new Set;
14457
- for (const t of existingTasks) {
14458
- const meta = t.metadata;
14459
- if (meta?.["source_file"] && meta?.["source_line"]) {
14460
- existingKeys.add(`${meta["source_file"]}:${meta["source_line"]}`);
14461
- }
14462
- }
14463
- for (const comment of allComments) {
14464
- const dedupKey = `${comment.file}:${comment.line}`;
14465
- if (existingKeys.has(dedupKey)) {
14466
- skipped++;
14467
- continue;
14468
- }
14469
- const taskTags = ["extracted", comment.tag.toLowerCase(), ...options.tags || []];
14470
- const task = createTask({
14471
- title: `[${comment.tag}] ${comment.message}`,
14472
- description: `Extracted from code comment in \`${comment.file}\` at line ${comment.line}:
14473
- \`\`\`
14474
- ${comment.raw.trim()}
14475
- \`\`\``,
14476
- priority: tagToPriority(comment.tag),
14477
- project_id: options.project_id,
14478
- task_list_id: options.task_list_id,
14479
- assigned_to: options.assigned_to,
14480
- agent_id: options.agent_id,
14481
- tags: taskTags,
14482
- metadata: {
14483
- source: "code_comment",
14484
- comment_type: comment.tag,
14485
- source_file: comment.file,
14486
- source_line: comment.line
16017
+ try {
16018
+ await pg.exec(PG_MIGRATIONS[i]);
16019
+ await pg.run("INSERT INTO _pg_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", i);
16020
+ result.applied.push(i);
16021
+ } catch (err) {
16022
+ result.errors.push(`Migration ${i}: ${err?.message ?? String(err)}`);
16023
+ break;
14487
16024
  }
14488
- }, db);
14489
- addTaskFile({
14490
- task_id: task.id,
14491
- path: comment.file,
14492
- note: `Line ${comment.line}: ${comment.tag} comment`
14493
- }, db);
14494
- tasks.push(task);
14495
- existingKeys.add(dedupKey);
16025
+ }
16026
+ } finally {
16027
+ await pg.close();
14496
16028
  }
14497
- return { comments: allComments, tasks, skipped };
16029
+ return result;
14498
16030
  }
14499
- var EXTRACT_TAGS, DEFAULT_EXTENSIONS, SKIP_DIRS;
14500
- var init_extract = __esm(() => {
14501
- init_tasks();
14502
- init_task_files();
14503
- EXTRACT_TAGS = ["TODO", "FIXME", "HACK", "XXX", "BUG", "NOTE"];
14504
- DEFAULT_EXTENSIONS = new Set([
14505
- ".ts",
14506
- ".tsx",
14507
- ".js",
14508
- ".jsx",
14509
- ".mjs",
14510
- ".cjs",
14511
- ".py",
14512
- ".rb",
14513
- ".go",
14514
- ".rs",
14515
- ".c",
14516
- ".cpp",
14517
- ".h",
14518
- ".hpp",
14519
- ".java",
14520
- ".kt",
14521
- ".swift",
14522
- ".cs",
14523
- ".php",
14524
- ".sh",
14525
- ".bash",
14526
- ".zsh",
14527
- ".lua",
14528
- ".sql",
14529
- ".r",
14530
- ".R",
14531
- ".yaml",
14532
- ".yml",
14533
- ".toml",
14534
- ".css",
14535
- ".scss",
14536
- ".less",
14537
- ".vue",
14538
- ".svelte",
14539
- ".ex",
14540
- ".exs",
14541
- ".erl",
14542
- ".hs",
14543
- ".ml",
14544
- ".mli",
14545
- ".clj",
14546
- ".cljs"
14547
- ]);
14548
- SKIP_DIRS = new Set([
14549
- "node_modules",
14550
- ".git",
14551
- "dist",
14552
- "build",
14553
- "out",
14554
- ".next",
14555
- ".turbo",
14556
- "coverage",
14557
- "__pycache__",
14558
- ".venv",
14559
- "venv",
14560
- "vendor",
14561
- "target",
14562
- ".cache",
14563
- ".parcel-cache"
14564
- ]);
16031
+ var init_pg_migrate = __esm(() => {
16032
+ init_dist();
16033
+ init_pg_migrations();
14565
16034
  });
14566
16035
 
14567
16036
  // src/mcp/index.ts
@@ -18827,16 +20296,16 @@ function searchTasks(options, projectId, taskListId, db) {
18827
20296
  init_tasks();
18828
20297
  init_config();
18829
20298
  init_sync_utils();
18830
- import { existsSync as existsSync7, readFileSync as readFileSync3, readdirSync as readdirSync3, writeFileSync as writeFileSync3 } from "fs";
18831
- import { join as join8 } from "path";
20299
+ import { existsSync as existsSync9, readFileSync as readFileSync5, readdirSync as readdirSync5, writeFileSync as writeFileSync3 } from "fs";
20300
+ import { join as join10 } from "path";
18832
20301
  function getTaskListDir(taskListId) {
18833
- return join8(HOME, ".claude", "tasks", taskListId);
20302
+ return join10(HOME, ".claude", "tasks", taskListId);
18834
20303
  }
18835
20304
  function readClaudeTask(dir, filename) {
18836
- return readJsonFile(join8(dir, filename));
20305
+ return readJsonFile(join10(dir, filename));
18837
20306
  }
18838
20307
  function writeClaudeTask(dir, task) {
18839
- writeJsonFile(join8(dir, `${task.id}.json`), task);
20308
+ writeJsonFile(join10(dir, `${task.id}.json`), task);
18840
20309
  }
18841
20310
  function toClaudeStatus(status) {
18842
20311
  if (status === "pending" || status === "in_progress" || status === "completed") {
@@ -18848,14 +20317,14 @@ function toSqliteStatus(status) {
18848
20317
  return status;
18849
20318
  }
18850
20319
  function readPrefixCounter(dir) {
18851
- const path = join8(dir, ".prefix-counter");
18852
- if (!existsSync7(path))
20320
+ const path = join10(dir, ".prefix-counter");
20321
+ if (!existsSync9(path))
18853
20322
  return 0;
18854
- const val = parseInt(readFileSync3(path, "utf-8").trim(), 10);
20323
+ const val = parseInt(readFileSync5(path, "utf-8").trim(), 10);
18855
20324
  return isNaN(val) ? 0 : val;
18856
20325
  }
18857
20326
  function writePrefixCounter(dir, value) {
18858
- writeFileSync3(join8(dir, ".prefix-counter"), String(value));
20327
+ writeFileSync3(join10(dir, ".prefix-counter"), String(value));
18859
20328
  }
18860
20329
  function formatPrefixedSubject(title, prefix, counter) {
18861
20330
  const padded = String(counter).padStart(5, "0");
@@ -18882,7 +20351,7 @@ function taskToClaudeTask(task, claudeTaskId, existingMeta) {
18882
20351
  }
18883
20352
  function pushToClaudeTaskList(taskListId, projectId, options = {}) {
18884
20353
  const dir = getTaskListDir(taskListId);
18885
- if (!existsSync7(dir))
20354
+ if (!existsSync9(dir))
18886
20355
  ensureDir2(dir);
18887
20356
  const filter = {};
18888
20357
  if (projectId)
@@ -18891,7 +20360,7 @@ function pushToClaudeTaskList(taskListId, projectId, options = {}) {
18891
20360
  const existingByTodosId = new Map;
18892
20361
  const files = listJsonFiles(dir);
18893
20362
  for (const f of files) {
18894
- const path = join8(dir, f);
20363
+ const path = join10(dir, f);
18895
20364
  const ct = readClaudeTask(dir, f);
18896
20365
  if (ct?.metadata?.["todos_id"]) {
18897
20366
  existingByTodosId.set(ct.metadata["todos_id"], { task: ct, mtimeMs: getFileMtimeMs(path) });
@@ -18978,10 +20447,10 @@ function pushToClaudeTaskList(taskListId, projectId, options = {}) {
18978
20447
  }
18979
20448
  function pullFromClaudeTaskList(taskListId, projectId, options = {}) {
18980
20449
  const dir = getTaskListDir(taskListId);
18981
- if (!existsSync7(dir)) {
20450
+ if (!existsSync9(dir)) {
18982
20451
  return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
18983
20452
  }
18984
- const files = readdirSync3(dir).filter((f) => f.endsWith(".json"));
20453
+ const files = readdirSync5(dir).filter((f) => f.endsWith(".json"));
18985
20454
  let pulled = 0;
18986
20455
  const errors2 = [];
18987
20456
  const prefer = options.prefer || "remote";
@@ -18998,7 +20467,7 @@ function pullFromClaudeTaskList(taskListId, projectId, options = {}) {
18998
20467
  }
18999
20468
  for (const f of files) {
19000
20469
  try {
19001
- const filePath = join8(dir, f);
20470
+ const filePath = join10(dir, f);
19002
20471
  const ct = readClaudeTask(dir, f);
19003
20472
  if (!ct)
19004
20473
  continue;
@@ -19069,27 +20538,27 @@ function syncClaudeTaskList(taskListId, projectId, options = {}) {
19069
20538
  init_tasks();
19070
20539
  init_sync_utils();
19071
20540
  init_config();
19072
- import { existsSync as existsSync8 } from "fs";
19073
- import { join as join9 } from "path";
20541
+ import { existsSync as existsSync10 } from "fs";
20542
+ import { join as join11 } from "path";
19074
20543
  function getTodosGlobalDir2() {
19075
- const newDir = join9(HOME, ".hasna", "todos");
19076
- const legacyDir = join9(HOME, ".todos");
19077
- if (!existsSync8(newDir) && existsSync8(legacyDir))
20544
+ const newDir = join11(HOME, ".hasna", "todos");
20545
+ const legacyDir = join11(HOME, ".todos");
20546
+ if (!existsSync10(newDir) && existsSync10(legacyDir))
19078
20547
  return legacyDir;
19079
20548
  return newDir;
19080
20549
  }
19081
20550
  function agentBaseDir(agent) {
19082
20551
  const key = `TODOS_${agent.toUpperCase()}_TASKS_DIR`;
19083
- return process.env[key] || getAgentTasksDir(agent) || process.env["TODOS_AGENT_TASKS_DIR"] || join9(getTodosGlobalDir2(), "agents");
20552
+ return process.env[key] || getAgentTasksDir(agent) || process.env["TODOS_AGENT_TASKS_DIR"] || join11(getTodosGlobalDir2(), "agents");
19084
20553
  }
19085
20554
  function getTaskListDir2(agent, taskListId) {
19086
- return join9(agentBaseDir(agent), agent, taskListId);
20555
+ return join11(agentBaseDir(agent), agent, taskListId);
19087
20556
  }
19088
20557
  function readAgentTask(dir, filename) {
19089
- return readJsonFile(join9(dir, filename));
20558
+ return readJsonFile(join11(dir, filename));
19090
20559
  }
19091
20560
  function writeAgentTask(dir, task) {
19092
- writeJsonFile(join9(dir, `${task.id}.json`), task);
20561
+ writeJsonFile(join11(dir, `${task.id}.json`), task);
19093
20562
  }
19094
20563
  function taskToAgentTask(task, externalId, existingMeta) {
19095
20564
  return {
@@ -19114,7 +20583,7 @@ function metadataKey(agent) {
19114
20583
  }
19115
20584
  function pushToAgentTaskList(agent, taskListId, projectId, options = {}) {
19116
20585
  const dir = getTaskListDir2(agent, taskListId);
19117
- if (!existsSync8(dir))
20586
+ if (!existsSync10(dir))
19118
20587
  ensureDir2(dir);
19119
20588
  const filter = {};
19120
20589
  if (projectId)
@@ -19123,7 +20592,7 @@ function pushToAgentTaskList(agent, taskListId, projectId, options = {}) {
19123
20592
  const existingByTodosId = new Map;
19124
20593
  const files = listJsonFiles(dir);
19125
20594
  for (const f of files) {
19126
- const path = join9(dir, f);
20595
+ const path = join11(dir, f);
19127
20596
  const at = readAgentTask(dir, f);
19128
20597
  if (at?.metadata?.["todos_id"]) {
19129
20598
  existingByTodosId.set(at.metadata["todos_id"], { task: at, mtimeMs: getFileMtimeMs(path) });
@@ -19197,7 +20666,7 @@ function pushToAgentTaskList(agent, taskListId, projectId, options = {}) {
19197
20666
  }
19198
20667
  function pullFromAgentTaskList(agent, taskListId, projectId, options = {}) {
19199
20668
  const dir = getTaskListDir2(agent, taskListId);
19200
- if (!existsSync8(dir)) {
20669
+ if (!existsSync10(dir)) {
19201
20670
  return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
19202
20671
  }
19203
20672
  const files = listJsonFiles(dir);
@@ -19216,7 +20685,7 @@ function pullFromAgentTaskList(agent, taskListId, projectId, options = {}) {
19216
20685
  }
19217
20686
  for (const f of files) {
19218
20687
  try {
19219
- const filePath = join9(dir, f);
20688
+ const filePath = join11(dir, f);
19220
20689
  const at = readAgentTask(dir, f);
19221
20690
  if (!at)
19222
20691
  continue;
@@ -19359,14 +20828,14 @@ init_config();
19359
20828
  init_database();
19360
20829
  init_checklists();
19361
20830
  init_types();
19362
- import { readFileSync as readFileSync5 } from "fs";
19363
- import { join as join11, dirname as dirname2 } from "path";
20831
+ import { readFileSync as readFileSync7 } from "fs";
20832
+ import { join as join13, dirname as dirname3 } from "path";
19364
20833
  import { fileURLToPath } from "url";
19365
20834
  function getMcpVersion() {
19366
20835
  try {
19367
- const __dir = dirname2(fileURLToPath(import.meta.url));
19368
- const pkgPath = join11(__dir, "..", "package.json");
19369
- return JSON.parse(readFileSync5(pkgPath, "utf-8")).version || "0.0.0";
20836
+ const __dir = dirname3(fileURLToPath(import.meta.url));
20837
+ const pkgPath = join13(__dir, "..", "package.json");
20838
+ return JSON.parse(readFileSync7(pkgPath, "utf-8")).version || "0.0.0";
19370
20839
  } catch {
19371
20840
  return "0.0.0";
19372
20841
  }
@@ -19402,6 +20871,7 @@ var STANDARD_EXCLUDED = new Set([
19402
20871
  "list_templates",
19403
20872
  "create_task_from_template",
19404
20873
  "delete_template",
20874
+ "update_template",
19405
20875
  "approve_task"
19406
20876
  ]);
19407
20877
  function shouldRegisterTool(name) {
@@ -21223,7 +22693,8 @@ if (shouldRegisterTool("create_task_from_template")) {
21223
22693
  }, async (params) => {
21224
22694
  try {
21225
22695
  const { taskFromTemplate: taskFromTemplate2 } = await Promise.resolve().then(() => (init_templates(), exports_templates));
21226
- const input = taskFromTemplate2(params.template_id, {
22696
+ const resolvedTemplateId = resolveId(params.template_id, "task_templates");
22697
+ const input = taskFromTemplate2(resolvedTemplateId, {
21227
22698
  title: params.title,
21228
22699
  description: params.description,
21229
22700
  priority: params.priority,
@@ -21242,13 +22713,37 @@ if (shouldRegisterTool("delete_template")) {
21242
22713
  server.tool("delete_template", "Delete a task template by ID.", { id: exports_external.string() }, async ({ id }) => {
21243
22714
  try {
21244
22715
  const { deleteTemplate: deleteTemplate2 } = await Promise.resolve().then(() => (init_templates(), exports_templates));
21245
- const deleted = deleteTemplate2(id);
22716
+ const resolvedId = resolveId(id, "task_templates");
22717
+ const deleted = deleteTemplate2(resolvedId);
21246
22718
  return { content: [{ type: "text", text: deleted ? "Template deleted." : "Template not found." }] };
21247
22719
  } catch (e) {
21248
22720
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
21249
22721
  }
21250
22722
  });
21251
22723
  }
22724
+ if (shouldRegisterTool("update_template")) {
22725
+ server.tool("update_template", "Update a task template's name, title pattern, description, priority, tags, or other fields.", {
22726
+ id: exports_external.string(),
22727
+ name: exports_external.string().optional(),
22728
+ title_pattern: exports_external.string().optional(),
22729
+ description: exports_external.string().optional(),
22730
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional(),
22731
+ tags: exports_external.array(exports_external.string()).optional(),
22732
+ project_id: exports_external.string().optional(),
22733
+ plan_id: exports_external.string().optional()
22734
+ }, async ({ id, ...updates }) => {
22735
+ try {
22736
+ const { updateTemplate: updateTemplate2 } = await Promise.resolve().then(() => (init_templates(), exports_templates));
22737
+ const resolvedId = resolveId(id, "task_templates");
22738
+ const t = updateTemplate2(resolvedId, updates);
22739
+ if (!t)
22740
+ return { content: [{ type: "text", text: `Template not found: ${id}` }], isError: true };
22741
+ return { content: [{ type: "text", text: `Template updated: ${t.id.slice(0, 8)} | ${t.name} | "${t.title_pattern}" | ${t.priority}` }] };
22742
+ } catch (e) {
22743
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
22744
+ }
22745
+ });
22746
+ }
21252
22747
  if (shouldRegisterTool("approve_task")) {
21253
22748
  server.tool("approve_task", "Approve a task with requires_approval=true.", {
21254
22749
  id: exports_external.string(),
@@ -22365,6 +23860,7 @@ if (shouldRegisterTool("search_tools")) {
22365
23860
  "list_templates",
22366
23861
  "create_task_from_template",
22367
23862
  "delete_template",
23863
+ "update_template",
22368
23864
  "bulk_update_tasks",
22369
23865
  "bulk_create_tasks",
22370
23866
  "get_task_stats",
@@ -22601,6 +24097,9 @@ if (shouldRegisterTool("describe_tools")) {
22601
24097
  delete_template: `Delete a task template.
22602
24098
  Params: id(string, req)
22603
24099
  Example: {id: 'a1b2c3d4'}`,
24100
+ update_template: `Update a task template's name, title pattern, or other fields.
24101
+ Params: id(string, req), name(string), title_pattern(string), description(string), priority(low|medium|high|critical), tags(string[]), project_id(string), plan_id(string)
24102
+ Example: {id: 'a1b2c3d4', name: 'Renamed Template', priority: 'critical'}`,
22604
24103
  get_active_work: `See all in-progress tasks and who is working on them.
22605
24104
  Params: project_id(string, optional), task_list_id(string, optional)
22606
24105
  Example: {project_id: 'a1b2c3d4'}`,
@@ -23411,6 +24910,49 @@ server.resource("task-lists", "todos://task-lists", { description: "All task lis
23411
24910
  const lists = listTaskLists();
23412
24911
  return { contents: [{ uri: "todos://task-lists", text: JSON.stringify(lists, null, 2), mimeType: "application/json" }] };
23413
24912
  });
24913
+ if (shouldRegisterTool("migrate_pg")) {
24914
+ server.tool("migrate_pg", "Apply PostgreSQL schema migrations to the configured RDS instance", {
24915
+ connection_string: exports_external.string().optional().describe("PostgreSQL connection string (overrides cloud config)")
24916
+ }, async ({ connection_string }) => {
24917
+ try {
24918
+ let connStr;
24919
+ if (connection_string) {
24920
+ connStr = connection_string;
24921
+ } else {
24922
+ const { getConnectionString: getConnectionString2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
24923
+ connStr = getConnectionString2("todos");
24924
+ }
24925
+ const { applyPgMigrations: applyPgMigrations2 } = await Promise.resolve().then(() => (init_pg_migrate(), exports_pg_migrate));
24926
+ const result = await applyPgMigrations2(connStr);
24927
+ const lines = [];
24928
+ if (result.applied.length > 0) {
24929
+ lines.push(`Applied ${result.applied.length} migration(s): ${result.applied.join(", ")}`);
24930
+ }
24931
+ if (result.alreadyApplied.length > 0) {
24932
+ lines.push(`Already applied: ${result.alreadyApplied.length} migration(s)`);
24933
+ }
24934
+ if (result.errors.length > 0) {
24935
+ lines.push(`Errors:
24936
+ ${result.errors.join(`
24937
+ `)}`);
24938
+ }
24939
+ if (result.applied.length === 0 && result.errors.length === 0) {
24940
+ lines.push("Schema is up to date.");
24941
+ }
24942
+ lines.push(`Total migrations: ${result.totalMigrations}`);
24943
+ return {
24944
+ content: [{ type: "text", text: lines.join(`
24945
+ `) }],
24946
+ isError: result.errors.length > 0
24947
+ };
24948
+ } catch (e) {
24949
+ return {
24950
+ content: [{ type: "text", text: `Migration failed: ${e?.message ?? String(e)}` }],
24951
+ isError: true
24952
+ };
24953
+ }
24954
+ });
24955
+ }
23414
24956
  registerCloudTools(server, "todos");
23415
24957
  async function main() {
23416
24958
  const transport = new StdioServerTransport;