@memtensor/memos-local-openclaw-plugin 1.0.4-beta.1 → 1.0.4-beta.10
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/.env.example +7 -0
- package/README.md +24 -24
- package/dist/capture/index.d.ts +1 -1
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +34 -2
- package/dist/capture/index.js.map +1 -1
- package/dist/client/connector.d.ts +1 -2
- package/dist/client/connector.d.ts.map +1 -1
- package/dist/client/connector.js +93 -26
- package/dist/client/connector.js.map +1 -1
- package/dist/client/hub.d.ts.map +1 -1
- package/dist/client/hub.js +22 -0
- package/dist/client/hub.js.map +1 -1
- package/dist/client/skill-sync.d.ts +7 -0
- package/dist/client/skill-sync.d.ts.map +1 -1
- package/dist/client/skill-sync.js +10 -0
- package/dist/client/skill-sync.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -2
- package/dist/config.js.map +1 -1
- package/dist/hub/server.d.ts +7 -0
- package/dist/hub/server.d.ts.map +1 -1
- package/dist/hub/server.js +277 -87
- package/dist/hub/server.js.map +1 -1
- package/dist/hub/user-manager.d.ts +2 -0
- package/dist/hub/user-manager.d.ts.map +1 -1
- package/dist/hub/user-manager.js +5 -1
- package/dist/hub/user-manager.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +37 -6
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +91 -1
- package/dist/recall/engine.js.map +1 -1
- package/dist/shared/llm-call.d.ts +1 -0
- package/dist/shared/llm-call.d.ts.map +1 -1
- package/dist/shared/llm-call.js +82 -8
- package/dist/shared/llm-call.js.map +1 -1
- package/dist/sharing/types.d.ts +1 -1
- package/dist/sharing/types.d.ts.map +1 -1
- package/dist/skill/evolver.d.ts +2 -0
- package/dist/skill/evolver.d.ts.map +1 -1
- package/dist/skill/evolver.js +3 -0
- package/dist/skill/evolver.js.map +1 -1
- package/dist/storage/ensure-binding.d.ts +12 -0
- package/dist/storage/ensure-binding.d.ts.map +1 -0
- package/dist/storage/ensure-binding.js +53 -0
- package/dist/storage/ensure-binding.js.map +1 -0
- package/dist/storage/sqlite.d.ts +74 -20
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +286 -118
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/telemetry.d.ts +12 -5
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +156 -40
- package/dist/telemetry.js.map +1 -1
- package/dist/tools/memory-search.d.ts +3 -1
- package/dist/tools/memory-search.d.ts.map +1 -1
- package/dist/tools/memory-search.js +3 -1
- package/dist/tools/memory-search.js.map +1 -1
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +2660 -889
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +30 -8
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +965 -193
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +384 -43
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -2
- package/scripts/postinstall.cjs +1 -1
- package/skill/memos-memory-guide/SKILL.md +64 -26
- package/src/capture/index.ts +37 -1
- package/src/client/connector.ts +91 -28
- package/src/client/hub.ts +18 -0
- package/src/client/skill-sync.ts +14 -0
- package/src/config.ts +0 -2
- package/src/hub/server.ts +259 -78
- package/src/hub/user-manager.ts +7 -3
- package/src/index.ts +10 -2
- package/src/ingest/providers/index.ts +41 -7
- package/src/recall/engine.ts +84 -1
- package/src/shared/llm-call.ts +97 -9
- package/src/sharing/types.ts +1 -1
- package/src/skill/evolver.ts +5 -0
- package/src/storage/ensure-binding.ts +52 -0
- package/src/storage/sqlite.ts +295 -144
- package/src/telemetry.ts +172 -41
- package/src/tools/memory-search.ts +2 -1
- package/src/types.ts +1 -2
- package/src/viewer/html.ts +2660 -889
- package/src/viewer/server.ts +888 -177
package/dist/storage/sqlite.js
CHANGED
|
@@ -146,11 +146,23 @@ class SqliteStore {
|
|
|
146
146
|
this.migrateSkillEmbeddingsAndFts();
|
|
147
147
|
this.migrateFtsToTrigram();
|
|
148
148
|
this.migrateHubTables();
|
|
149
|
+
this.migrateHubFtsToTrigram();
|
|
150
|
+
this.migrateLocalSharedTasksOwner();
|
|
149
151
|
this.log.debug("Database schema initialized");
|
|
150
152
|
}
|
|
151
153
|
migrateChunksIndexesForRecall() {
|
|
152
154
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_dedup_created ON chunks(dedup_status, created_at DESC)");
|
|
153
155
|
}
|
|
156
|
+
migrateLocalSharedTasksOwner() {
|
|
157
|
+
try {
|
|
158
|
+
const cols = this.db.prepare("PRAGMA table_info(local_shared_tasks)").all();
|
|
159
|
+
if (cols.length > 0 && !cols.some((c) => c.name === "original_owner")) {
|
|
160
|
+
this.db.exec("ALTER TABLE local_shared_tasks ADD COLUMN original_owner TEXT NOT NULL DEFAULT 'agent:main'");
|
|
161
|
+
this.log.info("Migrated: added original_owner column to local_shared_tasks");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch { /* table may not exist yet */ }
|
|
165
|
+
}
|
|
154
166
|
migrateOwnerFields() {
|
|
155
167
|
const chunkCols = this.db.prepare("PRAGMA table_info(chunks)").all();
|
|
156
168
|
if (!chunkCols.some((c) => c.name === "owner")) {
|
|
@@ -298,6 +310,54 @@ class SqliteStore {
|
|
|
298
310
|
this.log.warn(`Failed to migrate skills_fts to trigram: ${err}`);
|
|
299
311
|
}
|
|
300
312
|
}
|
|
313
|
+
migrateHubFtsToTrigram() {
|
|
314
|
+
const tables = [
|
|
315
|
+
{
|
|
316
|
+
fts: "hub_chunks_fts", source: "hub_chunks", columns: "summary, content",
|
|
317
|
+
triggers: ["hub_chunks_ai", "hub_chunks_ad", "hub_chunks_au"],
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
fts: "hub_skills_fts", source: "hub_skills", columns: "name, description",
|
|
321
|
+
triggers: ["hub_skills_ai", "hub_skills_ad", "hub_skills_au"],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
fts: "hub_memories_fts", source: "hub_memories", columns: "summary, content",
|
|
325
|
+
triggers: ["hub_memories_ai", "hub_memories_ad", "hub_memories_au"],
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
for (const t of tables) {
|
|
329
|
+
try {
|
|
330
|
+
const row = this.db.prepare(`SELECT sql FROM sqlite_master WHERE name='${t.fts}'`).get();
|
|
331
|
+
if (!row || !row.sql)
|
|
332
|
+
continue;
|
|
333
|
+
if (row.sql.includes("trigram"))
|
|
334
|
+
continue;
|
|
335
|
+
this.log.info(`Migrating ${t.fts} to trigram tokenizer...`);
|
|
336
|
+
for (const tr of t.triggers)
|
|
337
|
+
this.db.exec(`DROP TRIGGER IF EXISTS ${tr}`);
|
|
338
|
+
this.db.exec(`DROP TABLE IF EXISTS ${t.fts}`);
|
|
339
|
+
this.db.exec(`CREATE VIRTUAL TABLE ${t.fts} USING fts5(${t.columns}, content='${t.source}', content_rowid='rowid', tokenize='trigram')`);
|
|
340
|
+
this.db.exec(`
|
|
341
|
+
CREATE TRIGGER ${t.triggers[0]} AFTER INSERT ON ${t.source} BEGIN
|
|
342
|
+
INSERT INTO ${t.fts}(rowid, ${t.columns}) VALUES (new.rowid, ${t.columns.split(", ").map(c => "new." + c).join(", ")});
|
|
343
|
+
END;
|
|
344
|
+
CREATE TRIGGER ${t.triggers[1]} AFTER DELETE ON ${t.source} BEGIN
|
|
345
|
+
INSERT INTO ${t.fts}(${t.fts}, rowid, ${t.columns}) VALUES ('delete', old.rowid, ${t.columns.split(", ").map(c => "old." + c).join(", ")});
|
|
346
|
+
END;
|
|
347
|
+
CREATE TRIGGER ${t.triggers[2]} AFTER UPDATE ON ${t.source} BEGIN
|
|
348
|
+
INSERT INTO ${t.fts}(${t.fts}, rowid, ${t.columns}) VALUES ('delete', old.rowid, ${t.columns.split(", ").map(c => "old." + c).join(", ")});
|
|
349
|
+
INSERT INTO ${t.fts}(rowid, ${t.columns}) VALUES (new.rowid, ${t.columns.split(", ").map(c => "new." + c).join(", ")});
|
|
350
|
+
END
|
|
351
|
+
`);
|
|
352
|
+
this.db.exec(`INSERT INTO ${t.fts}(rowid, ${t.columns}) SELECT rowid, ${t.columns} FROM ${t.source}`);
|
|
353
|
+
const cnt = this.db.prepare(`SELECT COUNT(*) as c FROM ${t.fts}`).get().c;
|
|
354
|
+
this.log.info(`Migrated ${t.fts} to trigram: ${cnt} rows indexed`);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
this.log.warn(`Failed to migrate ${t.fts} to trigram: ${err}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
301
361
|
migrateTaskId() {
|
|
302
362
|
const cols = this.db.prepare("PRAGMA table_info(chunks)").all();
|
|
303
363
|
if (!cols.some((c) => c.name === "task_id")) {
|
|
@@ -503,15 +563,16 @@ class SqliteStore {
|
|
|
503
563
|
recordToolCall(toolName, durationMs, success) {
|
|
504
564
|
this.db.prepare("INSERT INTO tool_calls (tool_name, duration_ms, success, called_at) VALUES (?, ?, ?, ?)").run(toolName, Math.round(durationMs), success ? 1 : 0, Date.now());
|
|
505
565
|
}
|
|
506
|
-
getToolMetrics(minutes) {
|
|
507
|
-
const since = Date.now() - minutes * 60 * 1000;
|
|
566
|
+
getToolMetrics(minutes, fromMs, toMs) {
|
|
567
|
+
const since = fromMs ?? (Date.now() - minutes * 60 * 1000);
|
|
568
|
+
const until = toMs ?? Date.now();
|
|
508
569
|
const rows = this.db.prepare(`SELECT tool_name,
|
|
509
570
|
duration_ms,
|
|
510
571
|
success,
|
|
511
572
|
strftime('%Y-%m-%d %H:%M', called_at/1000, 'unixepoch', 'localtime') as minute_key
|
|
512
573
|
FROM tool_calls
|
|
513
|
-
WHERE called_at >= ?
|
|
514
|
-
ORDER BY called_at`).all(since);
|
|
574
|
+
WHERE called_at >= ? AND called_at <= ?
|
|
575
|
+
ORDER BY called_at`).all(since, until);
|
|
515
576
|
const toolSet = new Set();
|
|
516
577
|
const minuteMap = new Map();
|
|
517
578
|
const aggMap = new Map();
|
|
@@ -644,34 +705,27 @@ class SqliteStore {
|
|
|
644
705
|
shared_at INTEGER NOT NULL
|
|
645
706
|
);
|
|
646
707
|
|
|
708
|
+
CREATE TABLE IF NOT EXISTS local_shared_memories (
|
|
709
|
+
chunk_id TEXT PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE,
|
|
710
|
+
original_owner TEXT NOT NULL,
|
|
711
|
+
shared_at INTEGER NOT NULL
|
|
712
|
+
);
|
|
713
|
+
|
|
647
714
|
CREATE TABLE IF NOT EXISTS hub_users (
|
|
648
|
-
id
|
|
649
|
-
username
|
|
650
|
-
device_name
|
|
651
|
-
role
|
|
652
|
-
status
|
|
653
|
-
token_hash
|
|
654
|
-
created_at
|
|
655
|
-
approved_at
|
|
715
|
+
id TEXT PRIMARY KEY,
|
|
716
|
+
username TEXT NOT NULL UNIQUE,
|
|
717
|
+
device_name TEXT NOT NULL DEFAULT '',
|
|
718
|
+
role TEXT NOT NULL,
|
|
719
|
+
status TEXT NOT NULL,
|
|
720
|
+
token_hash TEXT NOT NULL DEFAULT '',
|
|
721
|
+
created_at INTEGER NOT NULL,
|
|
722
|
+
approved_at INTEGER,
|
|
723
|
+
last_ip TEXT NOT NULL DEFAULT '',
|
|
724
|
+
last_active_at INTEGER
|
|
656
725
|
);
|
|
657
726
|
CREATE INDEX IF NOT EXISTS idx_hub_users_status ON hub_users(status);
|
|
658
727
|
CREATE INDEX IF NOT EXISTS idx_hub_users_role ON hub_users(role);
|
|
659
728
|
|
|
660
|
-
CREATE TABLE IF NOT EXISTS hub_groups (
|
|
661
|
-
id TEXT PRIMARY KEY,
|
|
662
|
-
name TEXT NOT NULL UNIQUE,
|
|
663
|
-
description TEXT NOT NULL DEFAULT '',
|
|
664
|
-
created_at INTEGER NOT NULL
|
|
665
|
-
);
|
|
666
|
-
|
|
667
|
-
CREATE TABLE IF NOT EXISTS hub_group_members (
|
|
668
|
-
group_id TEXT NOT NULL REFERENCES hub_groups(id) ON DELETE CASCADE,
|
|
669
|
-
user_id TEXT NOT NULL REFERENCES hub_users(id) ON DELETE CASCADE,
|
|
670
|
-
joined_at INTEGER NOT NULL,
|
|
671
|
-
PRIMARY KEY (group_id, user_id)
|
|
672
|
-
);
|
|
673
|
-
CREATE INDEX IF NOT EXISTS idx_hub_group_members_user ON hub_group_members(user_id);
|
|
674
|
-
|
|
675
729
|
CREATE TABLE IF NOT EXISTS hub_tasks (
|
|
676
730
|
id TEXT PRIMARY KEY,
|
|
677
731
|
source_task_id TEXT NOT NULL,
|
|
@@ -713,7 +767,7 @@ class SqliteStore {
|
|
|
713
767
|
content,
|
|
714
768
|
content='hub_chunks',
|
|
715
769
|
content_rowid='rowid',
|
|
716
|
-
tokenize='
|
|
770
|
+
tokenize='trigram'
|
|
717
771
|
);
|
|
718
772
|
|
|
719
773
|
CREATE TRIGGER IF NOT EXISTS hub_chunks_ai AFTER INSERT ON hub_chunks BEGIN
|
|
@@ -763,7 +817,7 @@ class SqliteStore {
|
|
|
763
817
|
description,
|
|
764
818
|
content='hub_skills',
|
|
765
819
|
content_rowid='rowid',
|
|
766
|
-
tokenize='
|
|
820
|
+
tokenize='trigram'
|
|
767
821
|
);
|
|
768
822
|
|
|
769
823
|
CREATE TRIGGER IF NOT EXISTS hub_skills_ai AFTER INSERT ON hub_skills BEGIN
|
|
@@ -813,7 +867,7 @@ class SqliteStore {
|
|
|
813
867
|
content,
|
|
814
868
|
content='hub_memories',
|
|
815
869
|
content_rowid='rowid',
|
|
816
|
-
tokenize='
|
|
870
|
+
tokenize='trigram'
|
|
817
871
|
);
|
|
818
872
|
|
|
819
873
|
CREATE TRIGGER IF NOT EXISTS hub_memories_ai AFTER INSERT ON hub_memories BEGIN
|
|
@@ -833,6 +887,31 @@ class SqliteStore {
|
|
|
833
887
|
VALUES (new.rowid, new.summary, new.content);
|
|
834
888
|
END;
|
|
835
889
|
`);
|
|
890
|
+
this.db.exec(`
|
|
891
|
+
CREATE TABLE IF NOT EXISTS hub_notifications (
|
|
892
|
+
id TEXT PRIMARY KEY,
|
|
893
|
+
user_id TEXT NOT NULL,
|
|
894
|
+
type TEXT NOT NULL,
|
|
895
|
+
resource TEXT NOT NULL,
|
|
896
|
+
title TEXT NOT NULL,
|
|
897
|
+
message TEXT NOT NULL DEFAULT '',
|
|
898
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
899
|
+
created_at INTEGER NOT NULL
|
|
900
|
+
);
|
|
901
|
+
CREATE INDEX IF NOT EXISTS idx_hub_notif_user ON hub_notifications(user_id, read, created_at DESC);
|
|
902
|
+
`);
|
|
903
|
+
try {
|
|
904
|
+
const cols = this.db.prepare("PRAGMA table_info(hub_users)").all();
|
|
905
|
+
if (cols.length > 0 && !cols.some(c => c.name === "last_ip")) {
|
|
906
|
+
this.db.exec("ALTER TABLE hub_users ADD COLUMN last_ip TEXT NOT NULL DEFAULT ''");
|
|
907
|
+
this.log.info("Migrated: added last_ip column to hub_users");
|
|
908
|
+
}
|
|
909
|
+
if (cols.length > 0 && !cols.some(c => c.name === "last_active_at")) {
|
|
910
|
+
this.db.exec("ALTER TABLE hub_users ADD COLUMN last_active_at INTEGER");
|
|
911
|
+
this.log.info("Migrated: added last_active_at column to hub_users");
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
catch { /* table may not exist yet */ }
|
|
836
915
|
}
|
|
837
916
|
// ─── Write ───
|
|
838
917
|
insertChunk(chunk) {
|
|
@@ -959,6 +1038,30 @@ class SqliteStore {
|
|
|
959
1038
|
return [];
|
|
960
1039
|
}
|
|
961
1040
|
}
|
|
1041
|
+
hubMemoryPatternSearch(patterns, opts = {}) {
|
|
1042
|
+
if (patterns.length === 0)
|
|
1043
|
+
return [];
|
|
1044
|
+
const limit = opts.limit ?? 10;
|
|
1045
|
+
const conditions = patterns.map(() => "(hm.content LIKE ? OR hm.summary LIKE ?)");
|
|
1046
|
+
const params = [];
|
|
1047
|
+
for (const p of patterns) {
|
|
1048
|
+
params.push(`%${p}%`, `%${p}%`);
|
|
1049
|
+
}
|
|
1050
|
+
params.push(limit);
|
|
1051
|
+
try {
|
|
1052
|
+
const rows = this.db.prepare(`
|
|
1053
|
+
SELECT hm.id as memory_id, hm.content, hm.role, hm.created_at
|
|
1054
|
+
FROM hub_memories hm
|
|
1055
|
+
WHERE ${conditions.join(" OR ")}
|
|
1056
|
+
ORDER BY hm.created_at DESC
|
|
1057
|
+
LIMIT ?
|
|
1058
|
+
`).all(...params);
|
|
1059
|
+
return rows.map(r => ({ memoryId: r.memory_id, content: r.content, role: r.role, createdAt: r.created_at }));
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
return [];
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
962
1065
|
// ─── Vector Search ───
|
|
963
1066
|
getAllEmbeddings(ownerFilter) {
|
|
964
1067
|
let sql = `SELECT e.chunk_id, e.vector, e.dimensions FROM embeddings e
|
|
@@ -1097,6 +1200,8 @@ class SqliteStore {
|
|
|
1097
1200
|
"skill_embeddings",
|
|
1098
1201
|
"skill_versions",
|
|
1099
1202
|
"skills",
|
|
1203
|
+
"local_shared_memories",
|
|
1204
|
+
"local_shared_tasks",
|
|
1100
1205
|
"embeddings",
|
|
1101
1206
|
"chunks",
|
|
1102
1207
|
"tasks",
|
|
@@ -1519,6 +1624,61 @@ class SqliteStore {
|
|
|
1519
1624
|
const rows = this.db.prepare('SELECT task_id, hub_task_id, visibility, group_id, synced_chunks FROM local_shared_tasks').all();
|
|
1520
1625
|
return rows.map(r => ({ taskId: r.task_id, hubTaskId: r.hub_task_id, visibility: r.visibility, groupId: r.group_id, syncedChunks: r.synced_chunks }));
|
|
1521
1626
|
}
|
|
1627
|
+
// ─── Local Shared Memories (client-side tracking) ───
|
|
1628
|
+
markMemorySharedLocally(chunkId) {
|
|
1629
|
+
const chunk = this.getChunk(chunkId);
|
|
1630
|
+
if (!chunk)
|
|
1631
|
+
return { ok: false, reason: "not_found" };
|
|
1632
|
+
if (chunk.owner === "public") {
|
|
1633
|
+
const existing = this.getLocalSharedMemory(chunkId);
|
|
1634
|
+
return {
|
|
1635
|
+
ok: true,
|
|
1636
|
+
owner: "public",
|
|
1637
|
+
originalOwner: existing?.originalOwner ?? undefined,
|
|
1638
|
+
sharedAt: existing?.sharedAt ?? undefined,
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
const sharedAt = Date.now();
|
|
1642
|
+
this.db.transaction(() => {
|
|
1643
|
+
this.db.prepare(`
|
|
1644
|
+
INSERT INTO local_shared_memories (chunk_id, original_owner, shared_at)
|
|
1645
|
+
VALUES (?, ?, ?)
|
|
1646
|
+
ON CONFLICT(chunk_id) DO UPDATE SET
|
|
1647
|
+
original_owner = excluded.original_owner,
|
|
1648
|
+
shared_at = excluded.shared_at
|
|
1649
|
+
`).run(chunkId, chunk.owner, sharedAt);
|
|
1650
|
+
this.updateChunk(chunkId, { owner: "public" });
|
|
1651
|
+
})();
|
|
1652
|
+
return { ok: true, owner: "public", originalOwner: chunk.owner, sharedAt };
|
|
1653
|
+
}
|
|
1654
|
+
unmarkMemorySharedLocally(chunkId, fallbackOwner) {
|
|
1655
|
+
const chunk = this.getChunk(chunkId);
|
|
1656
|
+
if (!chunk)
|
|
1657
|
+
return { ok: false, reason: "not_found" };
|
|
1658
|
+
if (chunk.owner !== "public") {
|
|
1659
|
+
return { ok: true, owner: chunk.owner };
|
|
1660
|
+
}
|
|
1661
|
+
const existing = this.getLocalSharedMemory(chunkId);
|
|
1662
|
+
const restoreOwner = existing?.originalOwner ?? fallbackOwner;
|
|
1663
|
+
if (!restoreOwner || restoreOwner === "public") {
|
|
1664
|
+
return { ok: false, reason: "original_owner_missing" };
|
|
1665
|
+
}
|
|
1666
|
+
this.db.transaction(() => {
|
|
1667
|
+
this.updateChunk(chunkId, { owner: restoreOwner });
|
|
1668
|
+
this.db.prepare("DELETE FROM local_shared_memories WHERE chunk_id = ?").run(chunkId);
|
|
1669
|
+
})();
|
|
1670
|
+
return { ok: true, owner: restoreOwner, originalOwner: restoreOwner };
|
|
1671
|
+
}
|
|
1672
|
+
getLocalSharedMemory(chunkId) {
|
|
1673
|
+
const row = this.db.prepare("SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id = ?").get(chunkId);
|
|
1674
|
+
if (!row)
|
|
1675
|
+
return null;
|
|
1676
|
+
return {
|
|
1677
|
+
chunkId: row.chunk_id,
|
|
1678
|
+
originalOwner: row.original_owner,
|
|
1679
|
+
sharedAt: row.shared_at,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1522
1682
|
// ─── Hub Users / Groups ───
|
|
1523
1683
|
upsertHubUser(user) {
|
|
1524
1684
|
this.db.prepare(`
|
|
@@ -1538,65 +1698,49 @@ class SqliteStore {
|
|
|
1538
1698
|
const row = this.db.prepare('SELECT * FROM hub_users WHERE id = ?').get(userId);
|
|
1539
1699
|
if (!row)
|
|
1540
1700
|
return null;
|
|
1541
|
-
return
|
|
1701
|
+
return rowToHubUser(row);
|
|
1542
1702
|
}
|
|
1543
1703
|
listHubUsers(status) {
|
|
1544
1704
|
const rows = status
|
|
1545
1705
|
? this.db.prepare('SELECT * FROM hub_users WHERE status = ? ORDER BY created_at').all(status)
|
|
1546
1706
|
: this.db.prepare('SELECT * FROM hub_users ORDER BY created_at').all();
|
|
1547
|
-
return rows.map(
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
}
|
|
1559
|
-
listHubGroups() {
|
|
1560
|
-
const rows = this.db.prepare('SELECT * FROM hub_groups ORDER BY name').all();
|
|
1561
|
-
return rows.map(rowToHubGroup);
|
|
1562
|
-
}
|
|
1563
|
-
addHubGroupMember(groupId, userId, joinedAt = Date.now()) {
|
|
1564
|
-
this.db.prepare(`
|
|
1565
|
-
INSERT INTO hub_group_members (group_id, user_id, joined_at)
|
|
1566
|
-
VALUES (?, ?, ?)
|
|
1567
|
-
ON CONFLICT(group_id, user_id) DO UPDATE SET joined_at = excluded.joined_at
|
|
1568
|
-
`).run(groupId, userId, joinedAt);
|
|
1569
|
-
}
|
|
1570
|
-
getHubGroupById(groupId) {
|
|
1571
|
-
const row = this.db.prepare('SELECT * FROM hub_groups WHERE id = ?').get(groupId);
|
|
1572
|
-
return row ? rowToHubGroup(row) : undefined;
|
|
1573
|
-
}
|
|
1574
|
-
deleteHubGroup(groupId) {
|
|
1575
|
-
const result = this.db.prepare('DELETE FROM hub_groups WHERE id = ?').run(groupId);
|
|
1707
|
+
return rows.map(rowToHubUser);
|
|
1708
|
+
}
|
|
1709
|
+
deleteHubUser(userId, cleanResources = false) {
|
|
1710
|
+
if (cleanResources) {
|
|
1711
|
+
this.db.prepare('DELETE FROM hub_tasks WHERE source_user_id = ?').run(userId);
|
|
1712
|
+
this.db.prepare('DELETE FROM hub_skills WHERE source_user_id = ?').run(userId);
|
|
1713
|
+
this.db.prepare('DELETE FROM hub_memories WHERE source_user_id = ?').run(userId);
|
|
1714
|
+
const result = this.db.prepare('DELETE FROM hub_users WHERE id = ?').run(userId);
|
|
1715
|
+
return result.changes > 0;
|
|
1716
|
+
}
|
|
1717
|
+
const result = this.db.prepare("UPDATE hub_users SET status = 'removed', token_hash = '' WHERE id = ?").run(userId);
|
|
1576
1718
|
return result.changes > 0;
|
|
1577
1719
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1720
|
+
updateHubUserActivity(userId, ip, timestamp) {
|
|
1721
|
+
this.db.prepare('UPDATE hub_users SET last_ip = ?, last_active_at = ? WHERE id = ?').run(ip, timestamp ?? Date.now(), userId);
|
|
1722
|
+
}
|
|
1723
|
+
getHubUserContributions() {
|
|
1724
|
+
const result = {};
|
|
1725
|
+
const memRows = this.db.prepare('SELECT source_user_id, COUNT(*) as cnt FROM hub_memories GROUP BY source_user_id').all();
|
|
1726
|
+
const taskRows = this.db.prepare('SELECT source_user_id, COUNT(*) as cnt FROM hub_tasks GROUP BY source_user_id').all();
|
|
1727
|
+
const skillRows = this.db.prepare('SELECT source_user_id, COUNT(*) as cnt FROM hub_skills GROUP BY source_user_id').all();
|
|
1728
|
+
for (const r of memRows) {
|
|
1729
|
+
if (!result[r.source_user_id])
|
|
1730
|
+
result[r.source_user_id] = { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
1731
|
+
result[r.source_user_id].memoryCount = r.cnt;
|
|
1732
|
+
}
|
|
1733
|
+
for (const r of taskRows) {
|
|
1734
|
+
if (!result[r.source_user_id])
|
|
1735
|
+
result[r.source_user_id] = { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
1736
|
+
result[r.source_user_id].taskCount = r.cnt;
|
|
1737
|
+
}
|
|
1738
|
+
for (const r of skillRows) {
|
|
1739
|
+
if (!result[r.source_user_id])
|
|
1740
|
+
result[r.source_user_id] = { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
1741
|
+
result[r.source_user_id].skillCount = r.cnt;
|
|
1742
|
+
}
|
|
1743
|
+
return result;
|
|
1600
1744
|
}
|
|
1601
1745
|
// ─── Hub Shared Data ───
|
|
1602
1746
|
upsertHubTask(task) {
|
|
@@ -1616,6 +1760,10 @@ class SqliteStore {
|
|
|
1616
1760
|
const row = this.db.prepare('SELECT * FROM hub_tasks WHERE source_user_id = ? AND source_task_id = ?').get(sourceUserId, sourceTaskId);
|
|
1617
1761
|
return row ? rowToHubTask(row) : null;
|
|
1618
1762
|
}
|
|
1763
|
+
getHubTaskById(taskId) {
|
|
1764
|
+
const row = this.db.prepare('SELECT * FROM hub_tasks WHERE id = ?').get(taskId);
|
|
1765
|
+
return row ? rowToHubTask(row) : null;
|
|
1766
|
+
}
|
|
1619
1767
|
upsertHubChunk(chunk) {
|
|
1620
1768
|
if (!chunk.sourceTaskId)
|
|
1621
1769
|
throw new Error("sourceTaskId is required for hub chunk upserts");
|
|
@@ -1753,7 +1901,7 @@ class SqliteStore {
|
|
|
1753
1901
|
let rows;
|
|
1754
1902
|
if (sanitized) {
|
|
1755
1903
|
rows = this.db.prepare(`
|
|
1756
|
-
SELECT hs.id, hs.name, hs.description, hs.version, hs.visibility, '' AS group_name, hu.username AS owner_name, hs.quality_score,
|
|
1904
|
+
SELECT hs.id, hs.name, hs.description, hs.version, hs.visibility, '' AS group_name, hu.username AS owner_name, hu.status AS owner_status, hs.quality_score,
|
|
1757
1905
|
bm25(hub_skills_fts) as rank
|
|
1758
1906
|
FROM hub_skills_fts f
|
|
1759
1907
|
JOIN hub_skills hs ON hs.rowid = f.rowid
|
|
@@ -1765,7 +1913,7 @@ class SqliteStore {
|
|
|
1765
1913
|
}
|
|
1766
1914
|
else {
|
|
1767
1915
|
rows = this.db.prepare(`
|
|
1768
|
-
SELECT hs.id, hs.name, hs.description, hs.version, hs.visibility, '' AS group_name, hu.username AS owner_name, hs.quality_score,
|
|
1916
|
+
SELECT hs.id, hs.name, hs.description, hs.version, hs.visibility, '' AS group_name, hu.username AS owner_name, hu.status AS owner_status, hs.quality_score,
|
|
1769
1917
|
0 as rank
|
|
1770
1918
|
FROM hub_skills hs
|
|
1771
1919
|
LEFT JOIN hub_users hu ON hu.id = hs.source_user_id
|
|
@@ -1780,7 +1928,7 @@ class SqliteStore {
|
|
|
1780
1928
|
}
|
|
1781
1929
|
listVisibleHubTasks(userId, limit = 40) {
|
|
1782
1930
|
const rows = this.db.prepare(`
|
|
1783
|
-
SELECT t.*, u.username AS owner_name, NULL AS group_name,
|
|
1931
|
+
SELECT t.*, u.username AS owner_name, u.status AS owner_status, NULL AS group_name,
|
|
1784
1932
|
(SELECT COUNT(*) FROM hub_chunks c WHERE c.hub_task_id = t.id) AS chunk_count
|
|
1785
1933
|
FROM hub_tasks t
|
|
1786
1934
|
LEFT JOIN hub_users u ON u.id = t.source_user_id
|
|
@@ -1790,33 +1938,36 @@ class SqliteStore {
|
|
|
1790
1938
|
return rows.map(r => ({
|
|
1791
1939
|
id: r.id, sourceTaskId: r.source_task_id, sourceUserId: r.source_user_id,
|
|
1792
1940
|
title: r.title, summary: r.summary, groupId: r.group_id, groupName: r.group_name ?? null,
|
|
1793
|
-
visibility: r.visibility, ownerName: r.owner_name ?? "unknown", chunkCount: r.chunk_count ?? 0,
|
|
1941
|
+
visibility: r.visibility, ownerName: r.owner_name ?? "unknown", ownerStatus: r.owner_status ?? "", chunkCount: r.chunk_count ?? 0,
|
|
1794
1942
|
createdAt: r.created_at, updatedAt: r.updated_at,
|
|
1795
1943
|
}));
|
|
1796
1944
|
}
|
|
1797
1945
|
listAllHubTasks() {
|
|
1798
1946
|
const rows = this.db.prepare(`
|
|
1799
|
-
SELECT t.*, u.username AS owner_name,
|
|
1947
|
+
SELECT t.*, u.username AS owner_name, u.status AS owner_status,
|
|
1800
1948
|
(SELECT COUNT(*) FROM hub_chunks c WHERE c.hub_task_id = t.id) AS chunk_count
|
|
1801
1949
|
FROM hub_tasks t
|
|
1802
1950
|
LEFT JOIN hub_users u ON u.id = t.source_user_id
|
|
1803
|
-
LEFT JOIN hub_groups g ON g.id = t.group_id
|
|
1804
1951
|
ORDER BY t.updated_at DESC
|
|
1805
1952
|
`).all();
|
|
1806
1953
|
return rows.map(r => ({
|
|
1807
1954
|
id: r.id, sourceTaskId: r.source_task_id, sourceUserId: r.source_user_id,
|
|
1808
|
-
title: r.title, summary: r.summary, groupId: r.group_id, groupName:
|
|
1809
|
-
visibility: r.visibility, ownerName: r.owner_name ?? "unknown", chunkCount: r.chunk_count ?? 0,
|
|
1955
|
+
title: r.title, summary: r.summary, groupId: r.group_id, groupName: null,
|
|
1956
|
+
visibility: r.visibility, ownerName: r.owner_name ?? "unknown", ownerStatus: r.owner_status ?? "", chunkCount: r.chunk_count ?? 0,
|
|
1810
1957
|
createdAt: r.created_at, updatedAt: r.updated_at,
|
|
1811
1958
|
}));
|
|
1812
1959
|
}
|
|
1960
|
+
listHubChunksByTaskId(hubTaskId) {
|
|
1961
|
+
const rows = this.db.prepare('SELECT * FROM hub_chunks WHERE hub_task_id = ? ORDER BY created_at ASC').all(hubTaskId);
|
|
1962
|
+
return rows.map(rowToHubChunk);
|
|
1963
|
+
}
|
|
1813
1964
|
deleteHubTaskById(taskId) {
|
|
1814
1965
|
const info = this.db.prepare('DELETE FROM hub_tasks WHERE id = ?').run(taskId);
|
|
1815
1966
|
return info.changes > 0;
|
|
1816
1967
|
}
|
|
1817
1968
|
listVisibleHubSkills(userId, limit = 40) {
|
|
1818
1969
|
const rows = this.db.prepare(`
|
|
1819
|
-
SELECT s.*, u.username AS owner_name, NULL AS group_name
|
|
1970
|
+
SELECT s.*, u.username AS owner_name, u.status AS owner_status, NULL AS group_name
|
|
1820
1971
|
FROM hub_skills s
|
|
1821
1972
|
LEFT JOIN hub_users u ON u.id = s.source_user_id
|
|
1822
1973
|
ORDER BY s.updated_at DESC
|
|
@@ -1826,23 +1977,22 @@ class SqliteStore {
|
|
|
1826
1977
|
id: r.id, sourceSkillId: r.source_skill_id, sourceUserId: r.source_user_id,
|
|
1827
1978
|
name: r.name, description: r.description, version: r.version,
|
|
1828
1979
|
groupId: r.group_id, groupName: r.group_name ?? null, visibility: r.visibility,
|
|
1829
|
-
ownerName: r.owner_name ?? "unknown", qualityScore: r.quality_score,
|
|
1980
|
+
ownerName: r.owner_name ?? "unknown", ownerStatus: r.owner_status ?? "", qualityScore: r.quality_score,
|
|
1830
1981
|
createdAt: r.created_at, updatedAt: r.updated_at,
|
|
1831
1982
|
}));
|
|
1832
1983
|
}
|
|
1833
1984
|
listAllHubSkills() {
|
|
1834
1985
|
const rows = this.db.prepare(`
|
|
1835
|
-
SELECT s.*, u.username AS owner_name,
|
|
1986
|
+
SELECT s.*, u.username AS owner_name, u.status AS owner_status
|
|
1836
1987
|
FROM hub_skills s
|
|
1837
1988
|
LEFT JOIN hub_users u ON u.id = s.source_user_id
|
|
1838
|
-
LEFT JOIN hub_groups g ON g.id = s.group_id
|
|
1839
1989
|
ORDER BY s.updated_at DESC
|
|
1840
1990
|
`).all();
|
|
1841
1991
|
return rows.map(r => ({
|
|
1842
1992
|
id: r.id, sourceSkillId: r.source_skill_id, sourceUserId: r.source_user_id,
|
|
1843
1993
|
name: r.name, description: r.description, version: r.version,
|
|
1844
|
-
groupId: r.group_id, groupName:
|
|
1845
|
-
ownerName: r.owner_name ?? "unknown", qualityScore: r.quality_score,
|
|
1994
|
+
groupId: r.group_id, groupName: null, visibility: r.visibility,
|
|
1995
|
+
ownerName: r.owner_name ?? "unknown", ownerStatus: r.owner_status ?? "", qualityScore: r.quality_score,
|
|
1846
1996
|
createdAt: r.created_at, updatedAt: r.updated_at,
|
|
1847
1997
|
}));
|
|
1848
1998
|
}
|
|
@@ -1881,6 +2031,37 @@ class SqliteStore {
|
|
|
1881
2031
|
const info = this.db.prepare('DELETE FROM hub_memories WHERE id = ?').run(memoryId);
|
|
1882
2032
|
return info.changes > 0;
|
|
1883
2033
|
}
|
|
2034
|
+
// ─── Hub Notifications ───
|
|
2035
|
+
insertHubNotification(n) {
|
|
2036
|
+
this.db.prepare('INSERT INTO hub_notifications (id, user_id, type, resource, title, message, read, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)').run(n.id, n.userId, n.type, n.resource, n.title, n.message ?? '', Date.now());
|
|
2037
|
+
}
|
|
2038
|
+
hasRecentHubNotification(userId, type, resource, windowMs = 300_000) {
|
|
2039
|
+
const since = Date.now() - windowMs;
|
|
2040
|
+
const row = this.db.prepare('SELECT COUNT(*) AS cnt FROM hub_notifications WHERE user_id = ? AND type = ? AND resource = ? AND created_at > ?').get(userId, type, resource, since);
|
|
2041
|
+
return row.cnt > 0;
|
|
2042
|
+
}
|
|
2043
|
+
listHubNotifications(userId, opts) {
|
|
2044
|
+
const where = opts?.unreadOnly ? 'WHERE user_id = ? AND read = 0' : 'WHERE user_id = ?';
|
|
2045
|
+
const limit = opts?.limit ?? 50;
|
|
2046
|
+
const rows = this.db.prepare(`SELECT * FROM hub_notifications ${where} ORDER BY created_at DESC LIMIT ?`).all(userId, limit);
|
|
2047
|
+
return rows.map(r => ({ id: r.id, userId: r.user_id, type: r.type, resource: r.resource, title: r.title, message: r.message, read: !!r.read, createdAt: r.created_at }));
|
|
2048
|
+
}
|
|
2049
|
+
countUnreadHubNotifications(userId) {
|
|
2050
|
+
const row = this.db.prepare('SELECT COUNT(*) AS cnt FROM hub_notifications WHERE user_id = ? AND read = 0').get(userId);
|
|
2051
|
+
return row.cnt;
|
|
2052
|
+
}
|
|
2053
|
+
markHubNotificationsRead(userId, ids) {
|
|
2054
|
+
if (ids && ids.length > 0) {
|
|
2055
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
2056
|
+
this.db.prepare(`UPDATE hub_notifications SET read = 1 WHERE user_id = ? AND id IN (${placeholders})`).run(userId, ...ids);
|
|
2057
|
+
}
|
|
2058
|
+
else {
|
|
2059
|
+
this.db.prepare('UPDATE hub_notifications SET read = 1 WHERE user_id = ?').run(userId);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
clearHubNotifications(userId) {
|
|
2063
|
+
this.db.prepare('DELETE FROM hub_notifications WHERE user_id = ?').run(userId);
|
|
2064
|
+
}
|
|
1884
2065
|
upsertHubMemoryEmbedding(memoryId, vector) {
|
|
1885
2066
|
const buf = Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
|
|
1886
2067
|
this.db.prepare(`
|
|
@@ -1937,7 +2118,7 @@ class SqliteStore {
|
|
|
1937
2118
|
}
|
|
1938
2119
|
listVisibleHubMemories(userId, limit = 40) {
|
|
1939
2120
|
const rows = this.db.prepare(`
|
|
1940
|
-
SELECT m.*, u.username AS owner_name, NULL AS group_name
|
|
2121
|
+
SELECT m.*, u.username AS owner_name, u.status AS owner_status, NULL AS group_name
|
|
1941
2122
|
FROM hub_memories m
|
|
1942
2123
|
LEFT JOIN hub_users u ON u.id = m.source_user_id
|
|
1943
2124
|
ORDER BY m.updated_at DESC
|
|
@@ -1945,24 +2126,23 @@ class SqliteStore {
|
|
|
1945
2126
|
`).all(limit);
|
|
1946
2127
|
return rows.map(r => ({
|
|
1947
2128
|
id: r.id, sourceChunkId: r.source_chunk_id, sourceUserId: r.source_user_id,
|
|
1948
|
-
role: r.role, summary: r.summary, kind: r.kind,
|
|
2129
|
+
role: r.role, content: r.content ?? "", summary: r.summary, kind: r.kind,
|
|
1949
2130
|
groupId: r.group_id, groupName: r.group_name ?? null, visibility: r.visibility,
|
|
1950
|
-
ownerName: r.owner_name ?? "unknown", createdAt: r.created_at, updatedAt: r.updated_at,
|
|
2131
|
+
ownerName: r.owner_name ?? "unknown", ownerStatus: r.owner_status ?? "", createdAt: r.created_at, updatedAt: r.updated_at,
|
|
1951
2132
|
}));
|
|
1952
2133
|
}
|
|
1953
2134
|
listAllHubMemories() {
|
|
1954
2135
|
const rows = this.db.prepare(`
|
|
1955
|
-
SELECT m.*, u.username AS owner_name,
|
|
2136
|
+
SELECT m.*, u.username AS owner_name, u.status AS owner_status
|
|
1956
2137
|
FROM hub_memories m
|
|
1957
2138
|
LEFT JOIN hub_users u ON u.id = m.source_user_id
|
|
1958
|
-
LEFT JOIN hub_groups g ON g.id = m.group_id
|
|
1959
2139
|
ORDER BY m.updated_at DESC
|
|
1960
2140
|
`).all();
|
|
1961
2141
|
return rows.map(r => ({
|
|
1962
2142
|
id: r.id, sourceChunkId: r.source_chunk_id, sourceUserId: r.source_user_id,
|
|
1963
|
-
role: r.role, summary: r.summary, kind: r.kind,
|
|
1964
|
-
groupId: r.group_id, groupName:
|
|
1965
|
-
ownerName: r.owner_name ?? "unknown", createdAt: r.created_at, updatedAt: r.updated_at,
|
|
2143
|
+
role: r.role, content: r.content ?? "", summary: r.summary, kind: r.kind,
|
|
2144
|
+
groupId: r.group_id, groupName: null, visibility: r.visibility,
|
|
2145
|
+
ownerName: r.owner_name ?? "unknown", ownerStatus: r.owner_status ?? "", createdAt: r.created_at, updatedAt: r.updated_at,
|
|
1966
2146
|
}));
|
|
1967
2147
|
}
|
|
1968
2148
|
resolveCanonicalHubTaskId(taskId, sourceUserId, sourceTaskId) {
|
|
@@ -1987,12 +2167,6 @@ class SqliteStore {
|
|
|
1987
2167
|
}
|
|
1988
2168
|
throw new Error(`source skill not found for skillId=${skillId}`);
|
|
1989
2169
|
}
|
|
1990
|
-
attachGroupsToHubUser(user) {
|
|
1991
|
-
return {
|
|
1992
|
-
...user,
|
|
1993
|
-
groups: this.getGroupsForHubUser(user.id),
|
|
1994
|
-
};
|
|
1995
|
-
}
|
|
1996
2170
|
getSessionOwnerMap(sessionKeys) {
|
|
1997
2171
|
const result = new Map();
|
|
1998
2172
|
if (sessionKeys.length === 0)
|
|
@@ -2115,14 +2289,8 @@ function rowToHubUser(row) {
|
|
|
2115
2289
|
tokenHash: row.token_hash,
|
|
2116
2290
|
createdAt: row.created_at,
|
|
2117
2291
|
approvedAt: row.approved_at,
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
function rowToHubGroup(row) {
|
|
2121
|
-
return {
|
|
2122
|
-
id: row.id,
|
|
2123
|
-
name: row.name,
|
|
2124
|
-
description: row.description,
|
|
2125
|
-
createdAt: row.created_at,
|
|
2292
|
+
lastIp: row.last_ip || "",
|
|
2293
|
+
lastActiveAt: row.last_active_at ?? null,
|
|
2126
2294
|
};
|
|
2127
2295
|
}
|
|
2128
2296
|
function rowToHubTask(row) {
|