@memtensor/memos-local-openclaw-plugin 1.0.4-beta.5 → 1.0.4-beta.7

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.
Files changed (55) hide show
  1. package/README.md +23 -23
  2. package/dist/capture/index.d.ts +1 -1
  3. package/dist/capture/index.d.ts.map +1 -1
  4. package/dist/capture/index.js +28 -2
  5. package/dist/capture/index.js.map +1 -1
  6. package/dist/client/connector.d.ts +1 -2
  7. package/dist/client/connector.d.ts.map +1 -1
  8. package/dist/client/connector.js +18 -19
  9. package/dist/client/connector.js.map +1 -1
  10. package/dist/client/hub.d.ts.map +1 -1
  11. package/dist/client/hub.js +22 -0
  12. package/dist/client/hub.js.map +1 -1
  13. package/dist/client/skill-sync.d.ts +7 -0
  14. package/dist/client/skill-sync.d.ts.map +1 -1
  15. package/dist/client/skill-sync.js +10 -0
  16. package/dist/client/skill-sync.js.map +1 -1
  17. package/dist/hub/server.d.ts.map +1 -1
  18. package/dist/hub/server.js +101 -81
  19. package/dist/hub/server.js.map +1 -1
  20. package/dist/hub/user-manager.d.ts +2 -0
  21. package/dist/hub/user-manager.d.ts.map +1 -1
  22. package/dist/hub/user-manager.js +5 -1
  23. package/dist/hub/user-manager.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/storage/sqlite.d.ts +54 -20
  28. package/dist/storage/sqlite.d.ts.map +1 -1
  29. package/dist/storage/sqlite.js +185 -101
  30. package/dist/storage/sqlite.js.map +1 -1
  31. package/dist/tools/memory-search.d.ts +3 -1
  32. package/dist/tools/memory-search.d.ts.map +1 -1
  33. package/dist/tools/memory-search.js +3 -1
  34. package/dist/tools/memory-search.js.map +1 -1
  35. package/dist/viewer/html.d.ts.map +1 -1
  36. package/dist/viewer/html.js +1619 -629
  37. package/dist/viewer/html.js.map +1 -1
  38. package/dist/viewer/server.d.ts +14 -8
  39. package/dist/viewer/server.d.ts.map +1 -1
  40. package/dist/viewer/server.js +545 -141
  41. package/dist/viewer/server.js.map +1 -1
  42. package/index.ts +355 -41
  43. package/package.json +1 -1
  44. package/skill/memos-memory-guide/SKILL.md +64 -26
  45. package/src/capture/index.ts +29 -1
  46. package/src/client/connector.ts +15 -21
  47. package/src/client/hub.ts +18 -0
  48. package/src/client/skill-sync.ts +14 -0
  49. package/src/hub/server.ts +88 -74
  50. package/src/hub/user-manager.ts +7 -3
  51. package/src/index.ts +7 -2
  52. package/src/storage/sqlite.ts +192 -122
  53. package/src/tools/memory-search.ts +2 -1
  54. package/src/viewer/html.ts +1619 -629
  55. package/src/viewer/server.ts +506 -128
@@ -1,10 +1,10 @@
1
1
  import { randomUUID, createHash } from "crypto";
2
- import { issueUserToken } from "./auth";
2
+ import { issueUserToken, verifyUserToken } from "./auth";
3
3
  import type { Logger } from "../types";
4
4
  import type { UserInfo } from "../sharing/types";
5
5
  import type { SqliteStore } from "../storage/sqlite";
6
6
 
7
- type ManagedHubUser = UserInfo & { tokenHash: string; createdAt: number; approvedAt: number | null };
7
+ type ManagedHubUser = UserInfo & { tokenHash: string; createdAt: number; approvedAt: number | null; lastIp: string; lastActiveAt: number | null };
8
8
 
9
9
  export class HubUserManager {
10
10
  constructor(private store: SqliteStore, private log: Logger) {}
@@ -20,6 +20,8 @@ export class HubUserManager {
20
20
  tokenHash: "",
21
21
  createdAt: Date.now(),
22
22
  approvedAt: null,
23
+ lastIp: "",
24
+ lastActiveAt: null,
23
25
  };
24
26
  this.store.upsertHubUser(user);
25
27
  return user;
@@ -46,7 +48,7 @@ export class HubUserManager {
46
48
  if (bootstrapUserId) {
47
49
  const bootstrapUser = this.store.getHubUser(bootstrapUserId);
48
50
  if (bootstrapUser && bootstrapUser.role === "admin" && bootstrapUser.status === "active") {
49
- if (bootstrapToken && bootstrapUser.tokenHash === createHash("sha256").update(bootstrapToken).digest("hex")) {
51
+ if (bootstrapToken && bootstrapUser.tokenHash === createHash("sha256").update(bootstrapToken).digest("hex") && verifyUserToken(bootstrapToken, secret)) {
50
52
  return { user: bootstrapUser, token: bootstrapToken };
51
53
  }
52
54
  const refreshedToken = issueUserToken(
@@ -88,6 +90,8 @@ export class HubUserManager {
88
90
  tokenHash: "",
89
91
  createdAt: Date.now(),
90
92
  approvedAt: Date.now(),
93
+ lastIp: "",
94
+ lastActiveAt: null,
91
95
  };
92
96
  const token = issueUserToken(
93
97
  { userId: user.id, username: user.username, role: user.role, status: user.status },
package/src/index.ts CHANGED
@@ -64,8 +64,10 @@ export function initPlugin(opts: PluginInitOptions = {}): MemosLocalPlugin {
64
64
  const worker = new IngestWorker(store, embedder, ctx);
65
65
  const engine = new RecallEngine(store, embedder, ctx);
66
66
 
67
+ const sharedState = { lastSearchTime: 0 };
68
+
67
69
  const tools: ToolDefinition[] = [
68
- createMemorySearchTool(engine, store, ctx),
70
+ createMemorySearchTool(engine, store, ctx, sharedState),
69
71
  createMemoryTimelineTool(store),
70
72
  createMemoryGetTool(store),
71
73
  createNetworkMemoryDetailTool(store, ctx),
@@ -87,7 +89,10 @@ export function initPlugin(opts: PluginInitOptions = {}): MemosLocalPlugin {
87
89
  const turnId = uuid();
88
90
  const tag = ctx.config.capture?.evidenceWrapperTag ?? "STORED_MEMORY";
89
91
 
90
- const captured = captureMessages(messages, session, turnId, tag, ctx.log, owner);
92
+ const userSearchTime = sharedState.lastSearchTime || 0;
93
+ sharedState.lastSearchTime = 0;
94
+
95
+ const captured = captureMessages(messages, session, turnId, tag, ctx.log, owner, userSearchTime);
91
96
  if (captured.length > 0) {
92
97
  worker.enqueue(captured);
93
98
  }
@@ -3,7 +3,7 @@ import { createHash } from "crypto";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import type { Chunk, ChunkRef, DedupStatus, Task, TaskStatus, Skill, SkillStatus, SkillVisibility, SkillVersion, TaskSkillLink, TaskSkillRelation, Logger } from "../types";
6
- import type { GroupInfo, SharedVisibility, UserInfo, UserRole, UserStatus } from "../sharing/types";
6
+ import type { SharedVisibility, UserInfo, UserRole, UserStatus } from "../sharing/types";
7
7
 
8
8
  export class SqliteStore {
9
9
  private db: Database.Database;
@@ -112,6 +112,7 @@ export class SqliteStore {
112
112
  this.migrateSkillEmbeddingsAndFts();
113
113
  this.migrateFtsToTrigram();
114
114
  this.migrateHubTables();
115
+ this.migrateLocalSharedTasksOwner();
115
116
  this.log.debug("Database schema initialized");
116
117
  }
117
118
 
@@ -119,6 +120,16 @@ export class SqliteStore {
119
120
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_dedup_created ON chunks(dedup_status, created_at DESC)");
120
121
  }
121
122
 
123
+ private migrateLocalSharedTasksOwner(): void {
124
+ try {
125
+ const cols = this.db.prepare("PRAGMA table_info(local_shared_tasks)").all() as Array<{ name: string }>;
126
+ if (cols.length > 0 && !cols.some((c) => c.name === "original_owner")) {
127
+ this.db.exec("ALTER TABLE local_shared_tasks ADD COLUMN original_owner TEXT NOT NULL DEFAULT 'agent:main'");
128
+ this.log.info("Migrated: added original_owner column to local_shared_tasks");
129
+ }
130
+ } catch { /* table may not exist yet */ }
131
+ }
132
+
122
133
  private migrateOwnerFields(): void {
123
134
  const chunkCols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
124
135
  if (!chunkCols.some((c) => c.name === "owner")) {
@@ -516,12 +527,13 @@ export class SqliteStore {
516
527
  ).run(toolName, Math.round(durationMs), success ? 1 : 0, Date.now());
517
528
  }
518
529
 
519
- getToolMetrics(minutes: number): {
530
+ getToolMetrics(minutes: number, fromMs?: number, toMs?: number): {
520
531
  tools: string[];
521
532
  series: Array<{ minute: string; [tool: string]: number | string }>;
522
533
  aggregated: Array<{ tool: string; totalCalls: number; avgMs: number; p95Ms: number; errorCount: number }>;
523
534
  } {
524
- const since = Date.now() - minutes * 60 * 1000;
535
+ const since = fromMs ?? (Date.now() - minutes * 60 * 1000);
536
+ const until = toMs ?? Date.now();
525
537
 
526
538
  const rows = this.db.prepare(
527
539
  `SELECT tool_name,
@@ -529,9 +541,9 @@ export class SqliteStore {
529
541
  success,
530
542
  strftime('%Y-%m-%d %H:%M', called_at/1000, 'unixepoch', 'localtime') as minute_key
531
543
  FROM tool_calls
532
- WHERE called_at >= ?
544
+ WHERE called_at >= ? AND called_at <= ?
533
545
  ORDER BY called_at`,
534
- ).all(since) as Array<{ tool_name: string; duration_ms: number; success: number; minute_key: string }>;
546
+ ).all(since, until) as Array<{ tool_name: string; duration_ms: number; success: number; minute_key: string }>;
535
547
 
536
548
  const toolSet = new Set<string>();
537
549
  const minuteMap = new Map<string, Map<string, { total: number; count: number }>>();
@@ -683,34 +695,27 @@ export class SqliteStore {
683
695
  shared_at INTEGER NOT NULL
684
696
  );
685
697
 
698
+ CREATE TABLE IF NOT EXISTS local_shared_memories (
699
+ chunk_id TEXT PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE,
700
+ original_owner TEXT NOT NULL,
701
+ shared_at INTEGER NOT NULL
702
+ );
703
+
686
704
  CREATE TABLE IF NOT EXISTS hub_users (
687
- id TEXT PRIMARY KEY,
688
- username TEXT NOT NULL UNIQUE,
689
- device_name TEXT NOT NULL DEFAULT '',
690
- role TEXT NOT NULL,
691
- status TEXT NOT NULL,
692
- token_hash TEXT NOT NULL DEFAULT '',
693
- created_at INTEGER NOT NULL,
694
- approved_at INTEGER
705
+ id TEXT PRIMARY KEY,
706
+ username TEXT NOT NULL UNIQUE,
707
+ device_name TEXT NOT NULL DEFAULT '',
708
+ role TEXT NOT NULL,
709
+ status TEXT NOT NULL,
710
+ token_hash TEXT NOT NULL DEFAULT '',
711
+ created_at INTEGER NOT NULL,
712
+ approved_at INTEGER,
713
+ last_ip TEXT NOT NULL DEFAULT '',
714
+ last_active_at INTEGER
695
715
  );
696
716
  CREATE INDEX IF NOT EXISTS idx_hub_users_status ON hub_users(status);
697
717
  CREATE INDEX IF NOT EXISTS idx_hub_users_role ON hub_users(role);
698
718
 
699
- CREATE TABLE IF NOT EXISTS hub_groups (
700
- id TEXT PRIMARY KEY,
701
- name TEXT NOT NULL UNIQUE,
702
- description TEXT NOT NULL DEFAULT '',
703
- created_at INTEGER NOT NULL
704
- );
705
-
706
- CREATE TABLE IF NOT EXISTS hub_group_members (
707
- group_id TEXT NOT NULL REFERENCES hub_groups(id) ON DELETE CASCADE,
708
- user_id TEXT NOT NULL REFERENCES hub_users(id) ON DELETE CASCADE,
709
- joined_at INTEGER NOT NULL,
710
- PRIMARY KEY (group_id, user_id)
711
- );
712
- CREATE INDEX IF NOT EXISTS idx_hub_group_members_user ON hub_group_members(user_id);
713
-
714
719
  CREATE TABLE IF NOT EXISTS hub_tasks (
715
720
  id TEXT PRIMARY KEY,
716
721
  source_task_id TEXT NOT NULL,
@@ -872,6 +877,32 @@ export class SqliteStore {
872
877
  VALUES (new.rowid, new.summary, new.content);
873
878
  END;
874
879
  `);
880
+
881
+ this.db.exec(`
882
+ CREATE TABLE IF NOT EXISTS hub_notifications (
883
+ id TEXT PRIMARY KEY,
884
+ user_id TEXT NOT NULL,
885
+ type TEXT NOT NULL,
886
+ resource TEXT NOT NULL,
887
+ title TEXT NOT NULL,
888
+ message TEXT NOT NULL DEFAULT '',
889
+ read INTEGER NOT NULL DEFAULT 0,
890
+ created_at INTEGER NOT NULL
891
+ );
892
+ CREATE INDEX IF NOT EXISTS idx_hub_notif_user ON hub_notifications(user_id, read, created_at DESC);
893
+ `);
894
+
895
+ try {
896
+ const cols = this.db.prepare("PRAGMA table_info(hub_users)").all() as Array<{ name: string }>;
897
+ if (cols.length > 0 && !cols.some(c => c.name === "last_ip")) {
898
+ this.db.exec("ALTER TABLE hub_users ADD COLUMN last_ip TEXT NOT NULL DEFAULT ''");
899
+ this.log.info("Migrated: added last_ip column to hub_users");
900
+ }
901
+ if (cols.length > 0 && !cols.some(c => c.name === "last_active_at")) {
902
+ this.db.exec("ALTER TABLE hub_users ADD COLUMN last_active_at INTEGER");
903
+ this.log.info("Migrated: added last_active_at column to hub_users");
904
+ }
905
+ } catch { /* table may not exist yet */ }
875
906
  }
876
907
 
877
908
  // ─── Write ───
@@ -1213,6 +1244,8 @@ export class SqliteStore {
1213
1244
  "skill_embeddings",
1214
1245
  "skill_versions",
1215
1246
  "skills",
1247
+ "local_shared_memories",
1248
+ "local_shared_tasks",
1216
1249
  "embeddings",
1217
1250
  "chunks",
1218
1251
  "tasks",
@@ -1684,6 +1717,67 @@ export class SqliteStore {
1684
1717
  return rows.map(r => ({ taskId: r.task_id, hubTaskId: r.hub_task_id, visibility: r.visibility, groupId: r.group_id, syncedChunks: r.synced_chunks }));
1685
1718
  }
1686
1719
 
1720
+ // ─── Local Shared Memories (client-side tracking) ───
1721
+
1722
+ markMemorySharedLocally(chunkId: string): { ok: boolean; owner?: string; originalOwner?: string; sharedAt?: number; reason?: string } {
1723
+ const chunk = this.getChunk(chunkId);
1724
+ if (!chunk) return { ok: false, reason: "not_found" };
1725
+ if (chunk.owner === "public") {
1726
+ const existing = this.getLocalSharedMemory(chunkId);
1727
+ return {
1728
+ ok: true,
1729
+ owner: "public",
1730
+ originalOwner: existing?.originalOwner ?? undefined,
1731
+ sharedAt: existing?.sharedAt ?? undefined,
1732
+ };
1733
+ }
1734
+
1735
+ const sharedAt = Date.now();
1736
+ this.db.transaction(() => {
1737
+ this.db.prepare(`
1738
+ INSERT INTO local_shared_memories (chunk_id, original_owner, shared_at)
1739
+ VALUES (?, ?, ?)
1740
+ ON CONFLICT(chunk_id) DO UPDATE SET
1741
+ original_owner = excluded.original_owner,
1742
+ shared_at = excluded.shared_at
1743
+ `).run(chunkId, chunk.owner, sharedAt);
1744
+ this.updateChunk(chunkId, { owner: "public" });
1745
+ })();
1746
+
1747
+ return { ok: true, owner: "public", originalOwner: chunk.owner, sharedAt };
1748
+ }
1749
+
1750
+ unmarkMemorySharedLocally(chunkId: string, fallbackOwner?: string): { ok: boolean; owner?: string; originalOwner?: string; reason?: string } {
1751
+ const chunk = this.getChunk(chunkId);
1752
+ if (!chunk) return { ok: false, reason: "not_found" };
1753
+ if (chunk.owner !== "public") {
1754
+ return { ok: true, owner: chunk.owner };
1755
+ }
1756
+
1757
+ const existing = this.getLocalSharedMemory(chunkId);
1758
+ const restoreOwner = existing?.originalOwner ?? fallbackOwner;
1759
+ if (!restoreOwner || restoreOwner === "public") {
1760
+ return { ok: false, reason: "original_owner_missing" };
1761
+ }
1762
+
1763
+ this.db.transaction(() => {
1764
+ this.updateChunk(chunkId, { owner: restoreOwner });
1765
+ this.db.prepare("DELETE FROM local_shared_memories WHERE chunk_id = ?").run(chunkId);
1766
+ })();
1767
+
1768
+ return { ok: true, owner: restoreOwner, originalOwner: restoreOwner };
1769
+ }
1770
+
1771
+ getLocalSharedMemory(chunkId: string): { chunkId: string; originalOwner: string; sharedAt: number } | null {
1772
+ const row = this.db.prepare("SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id = ?").get(chunkId) as any;
1773
+ if (!row) return null;
1774
+ return {
1775
+ chunkId: row.chunk_id,
1776
+ originalOwner: row.original_owner,
1777
+ sharedAt: row.shared_at,
1778
+ };
1779
+ }
1780
+
1687
1781
  // ─── Hub Users / Groups ───
1688
1782
 
1689
1783
  upsertHubUser(user: HubUserRecord): void {
@@ -1704,74 +1798,39 @@ export class SqliteStore {
1704
1798
  getHubUser(userId: string): HubUserRecord | null {
1705
1799
  const row = this.db.prepare('SELECT * FROM hub_users WHERE id = ?').get(userId) as HubUserRow | undefined;
1706
1800
  if (!row) return null;
1707
- return this.attachGroupsToHubUser(rowToHubUser(row));
1801
+ return rowToHubUser(row);
1708
1802
  }
1709
1803
 
1710
1804
  listHubUsers(status?: UserStatus): HubUserRecord[] {
1711
1805
  const rows = status
1712
1806
  ? this.db.prepare('SELECT * FROM hub_users WHERE status = ? ORDER BY created_at').all(status) as HubUserRow[]
1713
1807
  : this.db.prepare('SELECT * FROM hub_users ORDER BY created_at').all() as HubUserRow[];
1714
- return rows.map((row) => this.attachGroupsToHubUser(rowToHubUser(row)));
1715
- }
1716
-
1717
- upsertHubGroup(group: HubGroupRecord): void {
1718
- this.db.prepare(`
1719
- INSERT INTO hub_groups (id, name, description, created_at)
1720
- VALUES (?, ?, ?, ?)
1721
- ON CONFLICT(id) DO UPDATE SET
1722
- name = excluded.name,
1723
- description = excluded.description,
1724
- created_at = excluded.created_at
1725
- `).run(group.id, group.name, group.description, group.createdAt);
1808
+ return rows.map(rowToHubUser);
1726
1809
  }
1727
1810
 
1728
- listHubGroups(): HubGroupRecord[] {
1729
- const rows = this.db.prepare('SELECT * FROM hub_groups ORDER BY name').all() as HubGroupRow[];
1730
- return rows.map(rowToHubGroup);
1731
- }
1732
-
1733
- addHubGroupMember(groupId: string, userId: string, joinedAt = Date.now()): void {
1734
- this.db.prepare(`
1735
- INSERT INTO hub_group_members (group_id, user_id, joined_at)
1736
- VALUES (?, ?, ?)
1737
- ON CONFLICT(group_id, user_id) DO UPDATE SET joined_at = excluded.joined_at
1738
- `).run(groupId, userId, joinedAt);
1739
- }
1740
-
1741
- getHubGroupById(groupId: string): HubGroupRecord | undefined {
1742
- const row = this.db.prepare('SELECT * FROM hub_groups WHERE id = ?').get(groupId) as HubGroupRow | undefined;
1743
- return row ? rowToHubGroup(row) : undefined;
1744
- }
1745
-
1746
- deleteHubGroup(groupId: string): boolean {
1747
- const result = this.db.prepare('DELETE FROM hub_groups WHERE id = ?').run(groupId);
1811
+ deleteHubUser(userId: string, cleanResources = false): boolean {
1812
+ if (cleanResources) {
1813
+ this.db.prepare('DELETE FROM hub_tasks WHERE source_user_id = ?').run(userId);
1814
+ this.db.prepare('DELETE FROM hub_skills WHERE source_user_id = ?').run(userId);
1815
+ this.db.prepare('DELETE FROM hub_memories WHERE source_user_id = ?').run(userId);
1816
+ }
1817
+ const result = this.db.prepare('DELETE FROM hub_users WHERE id = ?').run(userId);
1748
1818
  return result.changes > 0;
1749
1819
  }
1750
1820
 
1751
- listHubGroupMembers(groupId: string): Array<{ userId: string; username: string; joinedAt: number }> {
1752
- const rows = this.db.prepare(`
1753
- SELECT gm.user_id, hu.username, gm.joined_at
1754
- FROM hub_group_members gm
1755
- JOIN hub_users hu ON hu.id = gm.user_id
1756
- WHERE gm.group_id = ?
1757
- ORDER BY gm.joined_at
1758
- `).all(groupId) as Array<{ user_id: string; username: string; joined_at: number }>;
1759
- return rows.map(r => ({ userId: r.user_id, username: r.username, joinedAt: r.joined_at }));
1760
- }
1761
-
1762
- removeHubGroupMember(groupId: string, userId: string): void {
1763
- this.db.prepare('DELETE FROM hub_group_members WHERE group_id = ? AND user_id = ?').run(groupId, userId);
1821
+ updateHubUserActivity(userId: string, ip: string): void {
1822
+ this.db.prepare('UPDATE hub_users SET last_ip = ?, last_active_at = ? WHERE id = ?').run(ip, Date.now(), userId);
1764
1823
  }
1765
1824
 
1766
- getGroupsForHubUser(userId: string): GroupInfo[] {
1767
- const rows = this.db.prepare(`
1768
- SELECT g.*
1769
- FROM hub_group_members gm
1770
- JOIN hub_groups g ON g.id = gm.group_id
1771
- WHERE gm.user_id = ?
1772
- ORDER BY g.name
1773
- `).all(userId) as HubGroupRow[];
1774
- return rows.map((row) => ({ id: row.id, name: row.name, description: row.description || undefined }));
1825
+ getHubUserContributions(): Record<string, { memoryCount: number; taskCount: number; skillCount: number }> {
1826
+ const result: Record<string, { memoryCount: number; taskCount: number; skillCount: number }> = {};
1827
+ const memRows = this.db.prepare('SELECT source_user_id, COUNT(*) as cnt FROM hub_memories GROUP BY source_user_id').all() as Array<{ source_user_id: string; cnt: number }>;
1828
+ const taskRows = this.db.prepare('SELECT source_user_id, COUNT(*) as cnt FROM hub_tasks GROUP BY source_user_id').all() as Array<{ source_user_id: string; cnt: number }>;
1829
+ const skillRows = this.db.prepare('SELECT source_user_id, COUNT(*) as cnt FROM hub_skills GROUP BY source_user_id').all() as Array<{ source_user_id: string; cnt: number }>;
1830
+ for (const r of memRows) { if (!result[r.source_user_id]) result[r.source_user_id] = { memoryCount: 0, taskCount: 0, skillCount: 0 }; result[r.source_user_id].memoryCount = r.cnt; }
1831
+ for (const r of taskRows) { if (!result[r.source_user_id]) result[r.source_user_id] = { memoryCount: 0, taskCount: 0, skillCount: 0 }; result[r.source_user_id].taskCount = r.cnt; }
1832
+ for (const r of skillRows) { if (!result[r.source_user_id]) result[r.source_user_id] = { memoryCount: 0, taskCount: 0, skillCount: 0 }; result[r.source_user_id].skillCount = r.cnt; }
1833
+ return result;
1775
1834
  }
1776
1835
 
1777
1836
  // ─── Hub Shared Data ───
@@ -1795,6 +1854,11 @@ export class SqliteStore {
1795
1854
  return row ? rowToHubTask(row) : null;
1796
1855
  }
1797
1856
 
1857
+ getHubTaskById(taskId: string): HubTaskRecord | null {
1858
+ const row = this.db.prepare('SELECT * FROM hub_tasks WHERE id = ?').get(taskId) as HubTaskRow | undefined;
1859
+ return row ? rowToHubTask(row) : null;
1860
+ }
1861
+
1798
1862
  upsertHubChunk(chunk: HubChunkUpsertInput): void {
1799
1863
  if (!chunk.sourceTaskId) throw new Error("sourceTaskId is required for hub chunk upserts");
1800
1864
  const taskId = this.resolveCanonicalHubTaskId(chunk.hubTaskId, chunk.sourceUserId, chunk.sourceTaskId);
@@ -1985,16 +2049,15 @@ export class SqliteStore {
1985
2049
 
1986
2050
  listAllHubTasks(): Array<{ id: string; sourceTaskId: string; sourceUserId: string; title: string; summary: string; groupId: string | null; groupName: string | null; visibility: string; ownerName: string; chunkCount: number; createdAt: number; updatedAt: number }> {
1987
2051
  const rows = this.db.prepare(`
1988
- SELECT t.*, u.username AS owner_name, g.name AS group_name,
2052
+ SELECT t.*, u.username AS owner_name,
1989
2053
  (SELECT COUNT(*) FROM hub_chunks c WHERE c.hub_task_id = t.id) AS chunk_count
1990
2054
  FROM hub_tasks t
1991
2055
  LEFT JOIN hub_users u ON u.id = t.source_user_id
1992
- LEFT JOIN hub_groups g ON g.id = t.group_id
1993
2056
  ORDER BY t.updated_at DESC
1994
2057
  `).all() as any[];
1995
2058
  return rows.map(r => ({
1996
2059
  id: r.id, sourceTaskId: r.source_task_id, sourceUserId: r.source_user_id,
1997
- title: r.title, summary: r.summary, groupId: r.group_id, groupName: r.group_name ?? null,
2060
+ title: r.title, summary: r.summary, groupId: r.group_id, groupName: null as string | null,
1998
2061
  visibility: r.visibility, ownerName: r.owner_name ?? "unknown", chunkCount: r.chunk_count ?? 0,
1999
2062
  createdAt: r.created_at, updatedAt: r.updated_at,
2000
2063
  }));
@@ -2024,16 +2087,15 @@ export class SqliteStore {
2024
2087
 
2025
2088
  listAllHubSkills(): Array<{ id: string; sourceSkillId: string; sourceUserId: string; name: string; description: string; version: number; groupId: string | null; groupName: string | null; visibility: string; ownerName: string; qualityScore: number | null; createdAt: number; updatedAt: number }> {
2026
2089
  const rows = this.db.prepare(`
2027
- SELECT s.*, u.username AS owner_name, g.name AS group_name
2090
+ SELECT s.*, u.username AS owner_name
2028
2091
  FROM hub_skills s
2029
2092
  LEFT JOIN hub_users u ON u.id = s.source_user_id
2030
- LEFT JOIN hub_groups g ON g.id = s.group_id
2031
2093
  ORDER BY s.updated_at DESC
2032
2094
  `).all() as any[];
2033
2095
  return rows.map(r => ({
2034
2096
  id: r.id, sourceSkillId: r.source_skill_id, sourceUserId: r.source_user_id,
2035
2097
  name: r.name, description: r.description, version: r.version,
2036
- groupId: r.group_id, groupName: r.group_name ?? null, visibility: r.visibility,
2098
+ groupId: r.group_id, groupName: null as string | null, visibility: r.visibility,
2037
2099
  ownerName: r.owner_name ?? "unknown", qualityScore: r.quality_score,
2038
2100
  createdAt: r.created_at, updatedAt: r.updated_at,
2039
2101
  }));
@@ -2081,6 +2143,39 @@ export class SqliteStore {
2081
2143
  return info.changes > 0;
2082
2144
  }
2083
2145
 
2146
+ // ─── Hub Notifications ───
2147
+
2148
+ insertHubNotification(n: { id: string; userId: string; type: string; resource: string; title: string; message?: string }): void {
2149
+ this.db.prepare(
2150
+ 'INSERT INTO hub_notifications (id, user_id, type, resource, title, message, read, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)'
2151
+ ).run(n.id, n.userId, n.type, n.resource, n.title, n.message ?? '', Date.now());
2152
+ }
2153
+
2154
+ listHubNotifications(userId: string, opts?: { unreadOnly?: boolean; limit?: number }): Array<{ id: string; userId: string; type: string; resource: string; title: string; message: string; read: boolean; createdAt: number }> {
2155
+ const where = opts?.unreadOnly ? 'WHERE user_id = ? AND read = 0' : 'WHERE user_id = ?';
2156
+ const limit = opts?.limit ?? 50;
2157
+ const rows = this.db.prepare(`SELECT * FROM hub_notifications ${where} ORDER BY created_at DESC LIMIT ?`).all(userId, limit) as any[];
2158
+ 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 }));
2159
+ }
2160
+
2161
+ countUnreadHubNotifications(userId: string): number {
2162
+ const row = this.db.prepare('SELECT COUNT(*) AS cnt FROM hub_notifications WHERE user_id = ? AND read = 0').get(userId) as { cnt: number };
2163
+ return row.cnt;
2164
+ }
2165
+
2166
+ markHubNotificationsRead(userId: string, ids?: string[]): void {
2167
+ if (ids && ids.length > 0) {
2168
+ const placeholders = ids.map(() => '?').join(',');
2169
+ this.db.prepare(`UPDATE hub_notifications SET read = 1 WHERE user_id = ? AND id IN (${placeholders})`).run(userId, ...ids);
2170
+ } else {
2171
+ this.db.prepare('UPDATE hub_notifications SET read = 1 WHERE user_id = ?').run(userId);
2172
+ }
2173
+ }
2174
+
2175
+ clearHubNotifications(userId: string): void {
2176
+ this.db.prepare('DELETE FROM hub_notifications WHERE user_id = ?').run(userId);
2177
+ }
2178
+
2084
2179
  upsertHubMemoryEmbedding(memoryId: string, vector: Float32Array): void {
2085
2180
  const buf = Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
2086
2181
  this.db.prepare(`
@@ -2156,16 +2251,15 @@ export class SqliteStore {
2156
2251
 
2157
2252
  listAllHubMemories(): Array<{ id: string; sourceChunkId: string; sourceUserId: string; role: string; summary: string; kind: string; groupId: string | null; groupName: string | null; visibility: string; ownerName: string; createdAt: number; updatedAt: number }> {
2158
2253
  const rows = this.db.prepare(`
2159
- SELECT m.*, u.username AS owner_name, g.name AS group_name
2254
+ SELECT m.*, u.username AS owner_name
2160
2255
  FROM hub_memories m
2161
2256
  LEFT JOIN hub_users u ON u.id = m.source_user_id
2162
- LEFT JOIN hub_groups g ON g.id = m.group_id
2163
2257
  ORDER BY m.updated_at DESC
2164
2258
  `).all() as any[];
2165
2259
  return rows.map(r => ({
2166
2260
  id: r.id, sourceChunkId: r.source_chunk_id, sourceUserId: r.source_user_id,
2167
2261
  role: r.role, summary: r.summary, kind: r.kind,
2168
- groupId: r.group_id, groupName: r.group_name ?? null, visibility: r.visibility,
2262
+ groupId: r.group_id, groupName: null as string | null, visibility: r.visibility,
2169
2263
  ownerName: r.owner_name ?? "unknown", createdAt: r.created_at, updatedAt: r.updated_at,
2170
2264
  }));
2171
2265
  }
@@ -2190,13 +2284,6 @@ export class SqliteStore {
2190
2284
  throw new Error(`source skill not found for skillId=${skillId}`);
2191
2285
  }
2192
2286
 
2193
- private attachGroupsToHubUser(user: HubUserRecord): HubUserRecord {
2194
- return {
2195
- ...user,
2196
- groups: this.getGroupsForHubUser(user.id),
2197
- };
2198
- }
2199
-
2200
2287
  getSessionOwnerMap(sessionKeys: string[]): Map<string, string> {
2201
2288
  const result = new Map<string, string>();
2202
2289
  if (sessionKeys.length === 0) return result;
@@ -2408,6 +2495,8 @@ interface HubUserRecord extends UserInfo {
2408
2495
  tokenHash: string;
2409
2496
  createdAt: number;
2410
2497
  approvedAt: number | null;
2498
+ lastIp: string;
2499
+ lastActiveAt: number | null;
2411
2500
  }
2412
2501
 
2413
2502
  interface HubUserRow {
@@ -2419,6 +2508,8 @@ interface HubUserRow {
2419
2508
  token_hash: string;
2420
2509
  created_at: number;
2421
2510
  approved_at: number | null;
2511
+ last_ip: string;
2512
+ last_active_at: number | null;
2422
2513
  }
2423
2514
 
2424
2515
  function rowToHubUser(row: HubUserRow): HubUserRecord {
@@ -2432,29 +2523,8 @@ function rowToHubUser(row: HubUserRow): HubUserRecord {
2432
2523
  tokenHash: row.token_hash,
2433
2524
  createdAt: row.created_at,
2434
2525
  approvedAt: row.approved_at,
2435
- };
2436
- }
2437
-
2438
- interface HubGroupRecord {
2439
- id: string;
2440
- name: string;
2441
- description: string;
2442
- createdAt: number;
2443
- }
2444
-
2445
- interface HubGroupRow {
2446
- id: string;
2447
- name: string;
2448
- description: string;
2449
- created_at: number;
2450
- }
2451
-
2452
- function rowToHubGroup(row: HubGroupRow): HubGroupRecord {
2453
- return {
2454
- id: row.id,
2455
- name: row.name,
2456
- description: row.description,
2457
- createdAt: row.created_at,
2526
+ lastIp: row.last_ip || "",
2527
+ lastActiveAt: row.last_active_at ?? null,
2458
2528
  };
2459
2529
  }
2460
2530
 
@@ -24,7 +24,7 @@ function emptyHubResult(scope: HubScope): HubSearchResult {
24
24
  };
25
25
  }
26
26
 
27
- export function createMemorySearchTool(engine: RecallEngine, store?: SqliteStore, ctx?: PluginContext): ToolDefinition {
27
+ export function createMemorySearchTool(engine: RecallEngine, store?: SqliteStore, ctx?: PluginContext, sharedState?: { lastSearchTime: number }): ToolDefinition {
28
28
  return {
29
29
  name: "memory_search",
30
30
  description:
@@ -60,6 +60,7 @@ export function createMemorySearchTool(engine: RecallEngine, store?: SqliteStore
60
60
  },
61
61
  },
62
62
  handler: async (input) => {
63
+ if (sharedState) sharedState.lastSearchTime = Date.now();
63
64
  const query = (input.query as string) ?? "";
64
65
  const maxResults = input.maxResults as number | undefined;
65
66
  const minScore = input.minScore as number | undefined;