diff --git a/harnesses/cursor/extension/hivemind-cursor-extension-0.1.0.vsix b/harnesses/cursor/extension/hivemind-cursor-extension-0.1.0.vsix index afbcfb47..21230773 100644 Binary files a/harnesses/cursor/extension/hivemind-cursor-extension-0.1.0.vsix and b/harnesses/cursor/extension/hivemind-cursor-extension-0.1.0.vsix differ diff --git a/harnesses/cursor/extension/scripts/lib/deeplake.mjs b/harnesses/cursor/extension/scripts/lib/deeplake.mjs new file mode 100644 index 00000000..8ed685ff --- /dev/null +++ b/harnesses/cursor/extension/scripts/lib/deeplake.mjs @@ -0,0 +1,140 @@ +/** + * Self-contained Deeplake data helper for the Cursor extension loaders. + * + * The loader scripts run via plain `node` spawned from the packaged + * extension. The core Hivemind CLI source (repo-root `src/`) is neither + * shipped in the vsix nor compiled to `.js`, so the old loaders that did + * `import ... from "../../../../src/*.js"` always failed at runtime. This + * module reimplements just the small slice of behaviour the loaders need, + * with no dependency outside the scripts directory: reading credentials, + * running a SQL query against the Deeplake HTTP endpoint, escaping SQL + * literals, and deriving the per-repo graph key the core uses. + */ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +const DEFAULT_API_URL = "https://api.deeplake.ai"; + +export function loadCreds() { + try { + const raw = readFileSync(join(homedir(), ".deeplake", "credentials.json"), "utf-8"); + const creds = JSON.parse(raw); + if (!creds || !creds.token || !creds.orgId) return null; + return { + token: creds.token, + orgId: creds.orgId, + orgName: creds.orgName ?? creds.orgId, + userName: creds.userName ?? "", + workspaceId: creds.workspaceId ?? "default", + apiUrl: creds.apiUrl ?? DEFAULT_API_URL, + }; + } catch { + return null; + } +} + +/** Table names, matching core `src/config.ts` defaults plus env overrides. */ +export function tableNames() { + return { + memory: process.env.HIVEMIND_TABLE ?? "memory", + sessions: process.env.HIVEMIND_SESSIONS_TABLE ?? "sessions", + rules: process.env.HIVEMIND_RULES_TABLE ?? "hivemind_rules", + goals: process.env.HIVEMIND_GOALS_TABLE ?? "hivemind_goals", + }; +} + +/** Escape a string for a single-quoted SQL literal. Mirrors src/utils/sql.ts. */ +export function sqlStr(value) { + return String(value) + .replace(/\\/g, "\\\\") + .replace(/'/g, "''") + .replace(/\0/g, "") + // eslint-disable-next-line no-control-regex + .replace(/[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); +} + +/** Validate a SQL identifier (table/column). Mirrors src/utils/sql.ts. */ +export function sqlIdent(name) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`Invalid SQL identifier: ${JSON.stringify(name)}`); + } + return name; +} + +/** True when the API error means the table has not been created yet. */ +export function isMissingTableError(message) { + return /does not exist|no such table|not found/i.test(String(message ?? "")); +} + +/** + * Run a SQL query against the Deeplake query endpoint and return rows as + * plain objects. Mirrors the request shape in src/deeplake-api.ts. + */ +export async function query(creds, sql, timeoutMs = 8000) { + const resp = await fetch(`${creds.apiUrl}/workspaces/${creds.workspaceId}/tables/query`, { + method: "POST", + headers: { + Authorization: `Bearer ${creds.token}`, + "Content-Type": "application/json", + "X-Activeloop-Org-Id": creds.orgId, + "X-Deeplake-Client": "hivemind", + }, + signal: AbortSignal.timeout(timeoutMs), + body: JSON.stringify({ query: sql }), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Query failed: ${resp.status}: ${text.slice(0, 200)}`); + } + const raw = await resp.json().catch(() => null); + if (!raw || !Array.isArray(raw.rows) || !Array.isArray(raw.columns)) return []; + return raw.rows.map((row) => Object.fromEntries(raw.columns.map((col, i) => [col, row[i]]))); +} + +/** Collapse the surface forms of a git remote URL. Mirrors src/utils/repo-identity.ts. */ +export function normalizeGitRemoteUrl(url) { + let s = String(url).trim(); + const schemeMatch = s.match(/^([a-z][a-z0-9+.-]*):\/\//i); + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; + if (schemeMatch) s = s.slice(schemeMatch[0].length); + if (!scheme) { + const scp = s.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/); + if (scp) s = `${scp[1]}/${scp[2]}`; + } + s = s.replace(/^[^@/]+@/, ""); + const defaultPorts = { http: "80", https: "443", ssh: "22", git: "9418" }; + if (scheme && defaultPorts[scheme]) { + s = s.replace(new RegExp(`^([^/]+):${defaultPorts[scheme]}(/|$)`), "$1$2"); + } + s = s.replace(/\.git\/?$/i, ""); + s = s.replace(/\/+$/, ""); + return s.toLowerCase(); +} + +/** + * Stable per-repo key: sha1 of the normalized git remote (fallback to the + * absolute cwd), first 16 hex chars. Mirrors core deriveProjectKey so the + * extension resolves the SAME `~/.hivemind/graphs/` dir the CLI writes. + */ +export function deriveProjectKey(cwd) { + let signature = null; + try { + const raw = execSync("git config --get remote.origin.url", { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + signature = raw ? normalizeGitRemoteUrl(raw) : null; + } catch { + signature = null; + } + const input = signature ?? cwd; + return createHash("sha1").update(input).digest("hex").slice(0, 16); +} + +export function graphsHome() { + return process.env.HIVEMIND_GRAPHS_HOME ?? join(homedir(), ".hivemind", "graphs"); +} diff --git a/harnesses/cursor/extension/scripts/load-dashboard.mjs b/harnesses/cursor/extension/scripts/load-dashboard.mjs index 1966ba15..71bdccc3 100644 --- a/harnesses/cursor/extension/scripts/load-dashboard.mjs +++ b/harnesses/cursor/extension/scripts/load-dashboard.mjs @@ -1,5 +1,172 @@ -import { loadDashboardData } from "../../../../src/dashboard/data.js"; +/** + * Build the dashboard data envelope (KPIs + codebase graph snapshot). + * + * Self-contained: resolves the per-repo graph key the same way the core CLI + * does (see lib/deeplake.mjs deriveProjectKey) so it finds the snapshot the + * `hivemind graph build` command actually wrote under + * ~/.hivemind/graphs//snapshots. KPIs come from the org-stats cache the + * CLI maintains, falling back to local usage records, then to an empty state. + * Prints a DashboardDataEnvelope JSON to stdout. + */ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { loadCreds, deriveProjectKey, graphsHome } from "./lib/deeplake.mjs"; const cwd = process.argv[2] || process.cwd(); -const data = await loadDashboardData({ cwd }); -console.log(JSON.stringify(data)); + +const BYTES_PER_TOKEN = 4; +const SAVINGS_MULTIPLIER = 1.7; + +function bytesToSavedTokens(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return 0; + return (SAVINGS_MULTIPLIER - 1) * (bytes / BYTES_PER_TOKEN); +} + +function countUserGeneratedSkills(userName) { + if (!userName) return 0; + const dir = join(homedir(), ".claude", "skills"); + if (!existsSync(dir)) return 0; + const suffix = `--${userName}`; + try { + let count = 0; + for (const name of readdirSync(dir)) { + const idx = name.lastIndexOf(suffix); + if (idx > 0 && idx + suffix.length === name.length) count += 1; + } + return count; + } catch { + return 0; + } +} + +function readUsageRecords() { + const path = join(homedir(), ".deeplake", "usage-stats.jsonl"); + if (!existsSync(path)) return []; + const out = []; + try { + for (const line of readFileSync(path, "utf-8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const rec = JSON.parse(trimmed); + if (typeof rec.endedAt === "string" && typeof rec.sessionId === "string") { + out.push({ + memorySearchBytes: typeof rec.memorySearchBytes === "number" ? rec.memorySearchBytes : 0, + memorySearchCount: typeof rec.memorySearchCount === "number" ? rec.memorySearchCount : 0, + }); + } + } catch { + /* skip bad line */ + } + } + } catch { + return []; + } + return out; +} + +function readOrgStatsCache(creds) { + if (!creds) return null; + const path = join(homedir(), ".deeplake", "hivemind-stats-cache.json"); + if (!existsSync(path)) return null; + try { + const cache = JSON.parse(readFileSync(path, "utf-8")); + const data = cache?.data; + if (!data?.org) return null; + const fetchedAt = typeof cache.fetchedAt === "number" ? new Date(cache.fetchedAt).toISOString() : null; + const stale = typeof cache.fetchedAt === "number" ? Date.now() - cache.fetchedAt > 3600_000 : false; + return { org: data.org, user: data.user ?? data.org, fetchedAt, stale }; + } catch { + return null; + } +} + +function resolveSnapshot(repoDir) { + const snapshotsDir = join(repoDir, "snapshots"); + if (!existsSync(snapshotsDir)) return null; + let snapshotPath = null; + const pointer = join(repoDir, "latest-commit.txt"); + if (existsSync(pointer)) { + try { + const sha = readFileSync(pointer, "utf-8").trim(); + const candidate = join(snapshotsDir, `${sha}.json`); + if (sha && existsSync(candidate)) snapshotPath = candidate; + } catch { + /* fall through to newest-file scan */ + } + } + if (!snapshotPath) { + try { + const candidates = readdirSync(snapshotsDir) + .filter((n) => n.endsWith(".json")) + .map((n) => ({ full: join(snapshotsDir, n), mtime: statSync(join(snapshotsDir, n)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + if (candidates[0]) snapshotPath = candidates[0].full; + } catch { + return null; + } + } + if (!snapshotPath) return null; + try { + const parsed = JSON.parse(readFileSync(snapshotPath, "utf-8")); + if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.links)) return null; + return { + commitSha: parsed.graph?.commit_sha ?? null, + snapshotPath, + nodeCount: parsed.nodes.length, + edgeCount: parsed.links.length, + snapshot: parsed, + }; + } catch { + return null; + } +} + +const creds = loadCreds(); +const repoKey = deriveProjectKey(cwd); +const repoProject = basename(cwd); +const graph = resolveSnapshot(join(graphsHome(), repoKey)); +const skillsCreated = countUserGeneratedSkills(creds?.userName); + +const cache = readOrgStatsCache(creds); +let kpis; +if (cache) { + kpis = { + tokensSaved: bytesToSavedTokens(cache.org.memorySearchBytes ?? 0), + tokensSource: "org", + skillsCreated, + memorySearches: cache.org.memoryRecallCount ?? 0, + sessionsCount: cache.org.sessionsCount ?? null, + userTokensSaved: bytesToSavedTokens((cache.user ?? cache.org).memorySearchBytes ?? 0), + orgStatsFetchedAt: cache.fetchedAt, + orgStatsStale: cache.stale, + orgStatsOffline: false, + }; +} else { + const records = readUsageRecords(); + const localBytes = records.reduce((s, r) => s + r.memorySearchBytes, 0); + const localCount = records.reduce((s, r) => s + r.memorySearchCount, 0); + const has = records.length > 0; + kpis = { + tokensSaved: has ? bytesToSavedTokens(localBytes) : null, + tokensSource: has ? "local" : "none", + skillsCreated, + memorySearches: localCount, + sessionsCount: has ? records.length : null, + userTokensSaved: has ? bytesToSavedTokens(localBytes) : null, + orgStatsFetchedAt: null, + orgStatsStale: false, + orgStatsOffline: false, + }; +} + +process.stdout.write( + JSON.stringify({ + repoKey, + repoProject, + generatedAt: new Date().toISOString(), + kpis, + graph, + }), +); diff --git a/harnesses/cursor/extension/scripts/load-goals.mjs b/harnesses/cursor/extension/scripts/load-goals.mjs new file mode 100644 index 00000000..8bfbf2de --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-goals.mjs @@ -0,0 +1,62 @@ +/** + * List goals from the Deeplake `hivemind_goals` table. + * + * Self-contained: reads credentials and queries the Deeplake HTTP endpoint + * directly. Mirrors core src/commands/goal.ts goalList, with latest-version + * dedup per goal_id (the VFS write path appends a fresh row per overwrite). + * Prints a GoalsListResult JSON to stdout. + * + * argv[2]: filter — "mine" (default) or "all". + */ +import { loadCreds, query, sqlIdent, sqlStr, tableNames, isMissingTableError } from "./lib/deeplake.mjs"; + +const filter = process.argv[2] === "all" ? "all" : "mine"; + +function emit(obj) { + process.stdout.write(JSON.stringify(obj)); +} + +const creds = loadCreds(); +if (!creds) { + emit({ loggedOut: true, goals: [], message: "Log in with `hivemind login` to track team goals." }); + process.exit(0); +} + +const table = sqlIdent(tableNames().goals); +const where = filter === "mine" ? `WHERE owner = '${sqlStr(creds.userName)}'` : ""; + +let rows; +try { + rows = await query( + creds, + `SELECT goal_id, owner, status, content, version, created_at FROM "${table}" ${where} ORDER BY version DESC, created_at DESC LIMIT 200`, + ); +} catch (e) { + if (isMissingTableError(e?.message)) { + emit({ loggedOut: false, goals: [] }); + process.exit(0); + } + emit({ loggedOut: false, goals: [], message: "Could not load goals." }); + process.exit(0); +} + +const latest = new Map(); +for (const r of rows) { + const goalId = String(r.goal_id ?? ""); + if (!goalId || latest.has(goalId)) continue; + const text = String(r.content ?? "").split(/\r?\n/)[0].trim(); + latest.set(goalId, { + goalId, + owner: String(r.owner ?? ""), + status: String(r.status ?? ""), + text, + createdAt: String(r.created_at ?? ""), + }); +} + +const goals = [...latest.values()] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 50) + .map(({ goalId, owner, status, text }) => ({ goalId, owner, status, text })); + +emit({ loggedOut: false, goals }); diff --git a/harnesses/cursor/extension/scripts/load-rules.mjs b/harnesses/cursor/extension/scripts/load-rules.mjs index c7208bca..4fef11da 100644 --- a/harnesses/cursor/extension/scripts/load-rules.mjs +++ b/harnesses/cursor/extension/scripts/load-rules.mjs @@ -1,36 +1,69 @@ -import { loadConfig } from "../../../../src/config.js"; -import { DeeplakeApi } from "../../../../src/api/deeplake-api.js"; -import { listRules } from "../../../../src/rules/read.js"; +/** + * List team rules from the Deeplake `hivemind_rules` table. + * + * Self-contained: reads credentials and queries the Deeplake HTTP endpoint + * directly (see lib/deeplake.mjs). Mirrors the latest-version-per-rule dedup + * in core src/rules/read.ts. Prints a RulesListResult JSON to stdout. + */ +import { loadCreds, query, sqlIdent, tableNames, isMissingTableError } from "./lib/deeplake.mjs"; const status = process.argv[2] || "active"; const limit = parseInt(process.argv[3] || "10", 10); -const config = loadConfig(); -if (!config) { - console.log(JSON.stringify({ - loggedOut: true, - rules: [], - message: "Log in with `hivemind login` to manage team rules.", - })); +function emit(obj) { + process.stdout.write(JSON.stringify(obj)); +} + +const creds = loadCreds(); +if (!creds) { + emit({ loggedOut: true, rules: [], message: "Log in with `hivemind login` to manage team rules." }); process.exit(0); } -const api = new DeeplakeApi( - config.token, - config.apiUrl, - config.orgId, - config.workspaceId, - config.skillsTableName, -); -const query = (sql) => api.query(sql); -const rows = await listRules(query, config.rulesTableName, { status, limit }); -console.log(JSON.stringify({ +const table = sqlIdent(tableNames().rules); + +let rows; +try { + rows = await query(creds, `SELECT id, rule_id, text, scope, status, assigned_by, version, created_at FROM "${table}" ORDER BY version DESC, created_at DESC, id DESC`); +} catch (e) { + // The rules table is created lazily by the CLI on first write. Until then + // a read 400s with "does not exist" — that just means no rules yet. + if (isMissingTableError(e?.message)) { + emit({ loggedOut: false, rules: [] }); + process.exit(0); + } + emit({ loggedOut: false, rules: [], message: "Could not load rules." }); + process.exit(0); +} + +const latest = new Map(); +for (const r of rows) { + const versionRaw = r.version; + const version = typeof versionRaw === "number" ? versionRaw : Number(versionRaw); + if (!Number.isFinite(version)) continue; + const ruleId = String(r.rule_id ?? ""); + if (!ruleId || latest.has(ruleId)) continue; + latest.set(ruleId, { + id: String(r.id ?? ""), + rule_id: ruleId, + text: String(r.text ?? ""), + status: String(r.status ?? ""), + assigned_by: String(r.assigned_by ?? ""), + version, + created_at: String(r.created_at ?? ""), + }); +} + +const filtered = [...latest.values()].filter((r) => (status === "all" ? true : r.status === status)); +filtered.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id)); + +emit({ loggedOut: false, - rules: rows.map((r) => ({ + rules: filtered.slice(0, limit).map((r) => ({ id: r.rule_id, status: r.status, version: r.version, author: r.assigned_by, text: r.text, })), -})); +}); diff --git a/harnesses/cursor/extension/scripts/load-session-summary.mjs b/harnesses/cursor/extension/scripts/load-session-summary.mjs index 011c4866..f37f4df0 100644 --- a/harnesses/cursor/extension/scripts/load-session-summary.mjs +++ b/harnesses/cursor/extension/scripts/load-session-summary.mjs @@ -1,16 +1,25 @@ -import { readFileSync, existsSync } from "node:fs"; +/** + * Load a session summary: remote Deeplake memory table first, local disk + * fallback second. + * + * Self-contained: reads credentials and queries the Deeplake HTTP endpoint + * directly (see lib/deeplake.mjs). Mirrors the resolution order in the core + * memory summary path. Prints a SessionSummaryResult JSON to stdout. + */ +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { loadCredentials } from "../../../../src/commands/auth-creds.js"; -import { loadConfig } from "../../../../src/config.js"; -import { DeeplakeApi } from "../../../../src/deeplake-api.js"; -import { sqlStr, sqlIdent } from "../../../../src/utils/sql.js"; +import { loadCreds, query, sqlIdent, sqlStr, tableNames } from "./lib/deeplake.mjs"; const sessionId = process.argv[2]; const userArg = process.argv[3] ?? ""; +function emit(obj) { + process.stdout.write(JSON.stringify(obj)); +} + if (!sessionId || !/^[a-zA-Z0-9_-]{1,128}$/.test(sessionId)) { - console.log(JSON.stringify({ text: null, source: "invalid", message: "Invalid session id." })); + emit({ text: null, source: "invalid", message: "Invalid session id." }); process.exit(0); } @@ -32,30 +41,21 @@ function readLocal(userName) { } async function readRemote(creds) { - if (!creds?.token || !creds.userName || !creds.orgId) return { text: null, unreachable: false }; - const cfg = loadConfig(); + if (!creds || !creds.userName) return { text: null, unreachable: false }; let table; try { - table = sqlIdent(cfg?.tableName ?? "memory"); + table = sqlIdent(tableNames().memory); } catch { return { text: null, unreachable: false }; } - const api = new DeeplakeApi( - creds.token, - creds.apiUrl ?? "https://api.deeplake.ai", - creds.orgId, - creds.workspaceId ?? "default", - table, - ); const vpath = `/summaries/${creds.userName}/${sessionId}.md`; try { - const rows = await Promise.race([ - api.query( - `SELECT summary FROM "${table}" WHERE path = '${sqlStr(vpath)}' AND author = '${sqlStr(creds.userName)}' ` + - `AND summary <> '' ORDER BY last_update_date DESC LIMIT 1`, - ), - new Promise((resolve) => setTimeout(() => resolve(null), 4000)), - ]); + const rows = await query( + creds, + `SELECT summary FROM "${table}" WHERE path = '${sqlStr(vpath)}' AND author = '${sqlStr(creds.userName)}' ` + + `AND summary <> '' ORDER BY last_update_date DESC LIMIT 1`, + 4000, + ); if (!rows || rows.length === 0) return { text: null, unreachable: false }; const summary = rows[0]?.summary; return { text: typeof summary === "string" && summary.trim() ? summary : null, unreachable: false }; @@ -64,36 +64,28 @@ async function readRemote(creds) { } } -const creds = loadCredentials(); +const creds = loadCreds(); const userName = userArg || creds?.userName || "unknown"; const remote = await readRemote(creds); if (remote.text) { - console.log(JSON.stringify({ text: remote.text, source: "remote", message: null })); + emit({ text: remote.text, source: "remote", message: null }); process.exit(0); } const local = readLocal(userName); if (local) { - console.log(JSON.stringify({ text: local, source: "local", message: null })); + emit({ text: local, source: "local", message: null }); process.exit(0); } if (remote.unreachable) { - console.log( - JSON.stringify({ - text: null, - source: "unreachable", - message: "Memory table unreachable. Showing no summary until connectivity returns.", - }), - ); + emit({ + text: null, + source: "unreachable", + message: "Memory table unreachable. Showing no summary until connectivity returns.", + }); process.exit(0); } -console.log( - JSON.stringify({ - text: null, - source: "missing", - message: `No summary found for session ${sessionId}.`, - }), -); +emit({ text: null, source: "missing", message: `No summary found for session ${sessionId}.` }); diff --git a/harnesses/cursor/extension/scripts/load-sessions.mjs b/harnesses/cursor/extension/scripts/load-sessions.mjs index 305c3a8e..0bc9da47 100644 --- a/harnesses/cursor/extension/scripts/load-sessions.mjs +++ b/harnesses/cursor/extension/scripts/load-sessions.mjs @@ -1,10 +1,65 @@ -import { readUsageRecords } from "../../../../src/notifications/usage-tracker.js"; - -const records = readUsageRecords().slice(-20).reverse(); -console.log(JSON.stringify(records.map((r) => ({ - sessionId: r.sessionId, - endedAt: r.endedAt, - memorySearchCount: r.memorySearchCount, - project: r.project ?? null, - hadRecall: (r.memorySearchCount ?? 0) > 0, -})))); +/** + * List recent captured sessions from the Deeplake `sessions` table. + * + * Self-contained: reads credentials and queries the Deeplake HTTP endpoint + * directly. Mirrors the grouped listing in core src/commands/session-prune.ts + * (one row per session path, newest first). Sessions are scoped to the + * current repo's project when possible, falling back to all of the user's + * recent sessions. Prints RecentSession[] JSON to stdout. + */ +import { basename } from "node:path"; +import { loadCreds, query, sqlIdent, sqlStr, tableNames } from "./lib/deeplake.mjs"; + +const cwd = process.argv[2] || process.cwd(); + +function emit(arr) { + process.stdout.write(JSON.stringify(arr)); +} + +/** /sessions//___.jsonl -> sessionId */ +function extractSessionId(path) { + const m = String(path).match(/\/sessions\/[^/]+\/[^/]+_([^.]+)\.jsonl$/); + if (m) return m[1]; + return String(path).split("/").pop()?.replace(/\.jsonl$/, "") ?? String(path); +} + +const creds = loadCreds(); +if (!creds || !creds.userName) { + emit([]); + process.exit(0); +} + +const table = sqlIdent(tableNames().sessions); + +let rows; +try { + rows = await query( + creds, + `SELECT path, COUNT(*) as cnt, MAX(creation_date) as last_event, MAX(project) as project ` + + `FROM "${table}" WHERE author = '${sqlStr(creds.userName)}' ` + + `GROUP BY path ORDER BY last_event DESC LIMIT 100`, + ); +} catch { + emit([]); + process.exit(0); +} + +const all = rows.map((r) => { + const eventCount = Number(r.cnt) || 0; + return { + sessionId: extractSessionId(r.path), + endedAt: String(r.last_event ?? ""), + eventCount, + memorySearchCount: eventCount, + project: r.project ? String(r.project) : null, + hadRecall: eventCount > 0, + }; +}); + +// Prefer sessions from this repo's project; fall back to all when the +// project name does not line up with the captured `project` column. +const repoProject = basename(cwd); +const scoped = all.filter((s) => s.project && s.project === repoProject); +const result = (scoped.length > 0 ? scoped : all).slice(0, 20); + +emit(result); diff --git a/harnesses/cursor/extension/src/bridge/skill-sync.ts b/harnesses/cursor/extension/src/bridge/skill-sync.ts index 7d584a13..3d4afe3e 100644 --- a/harnesses/cursor/extension/src/bridge/skill-sync.ts +++ b/harnesses/cursor/extension/src/bridge/skill-sync.ts @@ -309,6 +309,7 @@ export function listLocalSkillsForPromoter(): Array<{ const workspace = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const globalRoot = canonicalSkillsRoot(); + const seen = new Set(); for (const name of listMinedSkillNames(workspace)) { const projectPath = workspace ? join(workspace, ".claude", "skills", name) : ""; const globalPath = join(globalRoot, name); @@ -319,6 +320,7 @@ export function listLocalSkillsForPromoter(): Array<{ path: projectPath, shareScope: readSkillShareScope(projectPath), }); + seen.add(name); } else if (existsSync(join(globalPath, "SKILL.md"))) { out.push({ dirName: name, @@ -326,8 +328,27 @@ export function listLocalSkillsForPromoter(): Array<{ path: globalPath, shareScope: readSkillShareScope(globalPath), }); + seen.add(name); } } + + // Reconcile with what the settings "skills synced" count reports: pulled + // skills (`--` dirs under the canonical root) are real, + // installed skills too. Surfacing them here keeps the Skills tab from + // claiming "no skills" while settings shows a non-zero synced count. + for (const dirName of listPulledSkillDirs(globalRoot)) { + if (seen.has(dirName)) continue; + const pulledPath = join(globalRoot, dirName); + if (!existsSync(join(pulledPath, "SKILL.md"))) continue; + const declared = readSkillShareScope(pulledPath); + out.push({ + dirName, + scope: "global", + path: pulledPath, + shareScope: declared === "unknown" ? "team" : declared, + }); + seen.add(dirName); + } return out; } diff --git a/harnesses/cursor/extension/src/webview/DashboardPanel.ts b/harnesses/cursor/extension/src/webview/DashboardPanel.ts index 7b7c34de..8246f9ec 100644 --- a/harnesses/cursor/extension/src/webview/DashboardPanel.ts +++ b/harnesses/cursor/extension/src/webview/DashboardPanel.ts @@ -8,9 +8,9 @@ import { loadGraphSnapshotFromEnvelope, parseGraphSnapshot } from "../graph/snap import type { GraphSnapshot } from "../graph/types"; import { logError } from "../utils/output"; import { getDashboardHtml } from "./html/dashboard-shell"; -import { loadDashboardData, loadRecentSessions, loadRulesList, loadSessionSummary, runHivemindCli, runHivemindCliAsync, invalidateOrgStatsCache } from "./data-bridge"; +import { loadDashboardData, loadGoalsList, loadRecentSessions, loadRulesList, loadSessionSummary, runHivemindCli, runHivemindCliAsync, invalidateOrgStatsCache } from "./data-bridge"; -type DashboardPane = "kpi" | "settings" | "sessions" | "graph" | "rules" | "skills"; +type DashboardPane = "kpi" | "settings" | "sessions" | "graph" | "rules" | "skills" | "goals"; const NEXT_STEPS_SECTION_RE = /^##\s+Next Steps\s*$/im; @@ -25,6 +25,7 @@ interface WebviewInboundMessage { orgName?: string; workspaceName?: string; rulesStatus?: string; + goalsFilter?: string; } interface ParsedRule { @@ -67,6 +68,7 @@ class DashboardController { private editorSync: EditorGraphSyncHandle | undefined; private snapshot: GraphSnapshot | null = null; private rulesStatusFilter = "active"; + private goalsFilter: "mine" | "all" = "mine"; private promotedSteps: Set; private refreshInFlight = false; private lastDashboardEnvelope: Awaited> | null = null; @@ -106,6 +108,7 @@ class DashboardController { await this.pushSessions(); await this.pushRules(); await this.pushSkills(); + await this.pushGoals(); } private post(message: Record): void { @@ -203,6 +206,22 @@ class DashboardController { if (msg.rulesStatus) this.rulesStatusFilter = msg.rulesStatus; await this.pushRules(); break; + case "goalsList": + if (msg.goalsFilter === "all" || msg.goalsFilter === "mine") this.goalsFilter = msg.goalsFilter; + await this.pushGoals(); + break; + case "goalAdd": + if (msg.text) { + const result = await runHivemindCli(["goal", "add", msg.text], workspaceRoot()); + this.post({ + type: "actionResult", + target: "goals", + ok: result.ok, + message: result.ok ? "Goal added." : result.stderr || "Failed to add goal.", + }); + if (result.ok) await this.pushGoals(); + } + break; case "rulesAdd": if (msg.text) { const result = await runHivemindCli(["rules", "add", msg.text, "--scope", "team"], workspaceRoot()); @@ -277,6 +296,7 @@ class DashboardController { ok: result.ok, message: result.ok ? `Goal created: "${msg.text}"` : result.stderr || "Failed to create goal.", }); + if (result.ok) await this.pushGoals(); } break; case "switchWorkspace": @@ -427,6 +447,20 @@ class DashboardController { this.post({ type: "rules", rules: result.rules, loggedOut: false }); } + private async pushGoals(): Promise { + const result = await loadGoalsList(this.goalsFilter); + if (result.loggedOut) { + this.post({ + type: "goals", + goals: [], + loggedOut: true, + message: result.message ?? "Log in with `hivemind login` to track goals.", + }); + return; + } + this.post({ type: "goals", goals: result.goals, loggedOut: false }); + } + private async pushSkills(): Promise { const auth = await detectAuthState(); if (auth.state !== "logged_in") { diff --git a/harnesses/cursor/extension/src/webview/data-bridge.ts b/harnesses/cursor/extension/src/webview/data-bridge.ts index 971bcc6a..9f86ec47 100644 --- a/harnesses/cursor/extension/src/webview/data-bridge.ts +++ b/harnesses/cursor/extension/src/webview/data-bridge.ts @@ -1,4 +1,5 @@ import { spawn, execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; @@ -44,10 +45,17 @@ export interface RecentSession { sessionId: string; endedAt: string; memorySearchCount: number; + eventCount?: number; project?: string | null; hadRecall?: boolean; } +export interface GoalsListResult { + loggedOut: boolean; + goals: Array<{ goalId: string; owner: string; status: string; text: string }>; + message?: string; +} + function repoRootFromExtension(): string { return join(__dirname, "..", "..", "..", ".."); } @@ -86,15 +94,48 @@ function readUsageRecords(): Array<{ endedAt: string; sessionId: string; memoryS } } +/** Collapse the surface forms of a git remote URL. Mirrors core src/utils/repo-identity.ts. */ +function normalizeGitRemoteUrl(url: string): string { + let s = url.trim(); + const schemeMatch = s.match(/^([a-z][a-z0-9+.-]*):\/\//i); + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; + if (schemeMatch) s = s.slice(schemeMatch[0].length); + if (!scheme) { + const scp = s.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/); + if (scp) s = `${scp[1]}/${scp[2]}`; + } + s = s.replace(/^[^@/]+@/, ""); + const defaultPorts: Record = { http: "80", https: "443", ssh: "22", git: "9418" }; + if (scheme && defaultPorts[scheme]) { + s = s.replace(new RegExp(`^([^/]+):${defaultPorts[scheme]}(/|$)`), "$1$2"); + } + s = s.replace(/\.git\/?$/i, ""); + s = s.replace(/\/+$/, ""); + return s.toLowerCase(); +} + +/** + * Stable per-repo key: sha1 of the normalized git remote (fallback to cwd), + * first 16 hex chars. Mirrors core deriveProjectKey so the fallback resolves + * the SAME ~/.hivemind/graphs/ dir that `hivemind graph build` writes. + */ function deriveProjectKey(cwd: string): { key: string; project: string } { + let project = basename(cwd); + let signature: string | null = null; try { - const out = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8" }).trim(); - const key = Buffer.from(out).toString("hex").slice(0, 16); - return { key, project: basename(out) }; + const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8" }).trim(); + if (top) project = basename(top); } catch { - const key = Buffer.from(cwd).toString("hex").slice(0, 16); - return { key, project: basename(cwd) }; + /* not a git repo */ } + try { + const raw = execFileSync("git", ["config", "--get", "remote.origin.url"], { cwd, encoding: "utf-8" }).trim(); + signature = raw ? normalizeGitRemoteUrl(raw) : null; + } catch { + signature = null; + } + const key = createHash("sha1").update(signature ?? cwd).digest("hex").slice(0, 16); + return { key, project }; } function resolveSnapshot(repoDir: string): DashboardGraphSummary | null { @@ -212,7 +253,7 @@ export async function loadRecentSessions(_cwd: string): Promise const scriptPath = join(__dirname, "..", "..", "scripts", "load-sessions.mjs"); if (existsSync(scriptPath)) { return new Promise((resolve) => { - const child = spawn(process.execPath, [scriptPath], { + const child = spawn(process.execPath, [scriptPath, _cwd], { cwd: repoRootFromExtension(), env: { ...process.env, NODE_OPTIONS: "" }, stdio: ["ignore", "pipe", "pipe"], @@ -279,6 +320,32 @@ export async function loadRulesList(status: string, limit = 10): Promise { + const scriptPath = join(__dirname, "..", "..", "scripts", "load-goals.mjs"); + if (!existsSync(scriptPath)) { + return { loggedOut: true, goals: [], message: "Goals loader unavailable." }; + } + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath, filter], { + cwd: repoRootFromExtension(), + env: { ...process.env, NODE_OPTIONS: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.on("close", () => { + try { + resolve(JSON.parse(stdout) as GoalsListResult); + } catch { + resolve({ loggedOut: false, goals: [], message: "Failed to parse goals." }); + } + }); + child.on("error", () => resolve({ loggedOut: false, goals: [], message: "Failed to load goals." })); + }); +} + /** Load session summary from remote memory table with local disk fallback. */ export async function loadSessionSummary(sessionId: string, cwd: string): Promise { const creds = readJson<{ userName?: string }>(credentialsPath()); diff --git a/harnesses/cursor/extension/src/webview/html/dashboard-shell.ts b/harnesses/cursor/extension/src/webview/html/dashboard-shell.ts index fdd7ee97..278c9985 100644 --- a/harnesses/cursor/extension/src/webview/html/dashboard-shell.ts +++ b/harnesses/cursor/extension/src/webview/html/dashboard-shell.ts @@ -106,6 +106,7 @@ export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.U +
@@ -191,6 +192,19 @@ export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.U
    +
    +
    + + + +
    + +
      +

      +

      Local skills available under Claude/Cursor skill directories. Use "Promote to team" to share a skill so teammates pull it on their next session.

      @@ -256,6 +270,27 @@ export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.U document.getElementById("rules-status-filter").addEventListener("change", (e) => { post("rulesList", { rulesStatus: e.target.value }); }); + document.getElementById("goals-filter").addEventListener("change", (e) => { + post("goalsList", { goalsFilter: e.target.value }); + }); + document.getElementById("btn-goal-add").addEventListener("click", () => { + const text = document.getElementById("goal-text").value.trim(); + const errEl = document.getElementById("goal-add-error"); + if (!text) return; + if (text.includes("\\n") || text.includes("\\r")) { + errEl.hidden = false; + errEl.textContent = "Goal text must be a single line (no newlines)."; + return; + } + if (text.length > 2000) { + errEl.hidden = false; + errEl.textContent = "Goal text exceeds 2000 characters."; + return; + } + errEl.hidden = true; + post("goalAdd", { text }); + document.getElementById("goal-text").value = ""; + }); document.getElementById("btn-org-switch").addEventListener("click", () => { const orgName = document.getElementById("org-switch-input").value.trim(); if (orgName) post("switchOrg", { orgName }); @@ -337,8 +372,9 @@ export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.U ul.innerHTML = sessions.map(s => { const recall = s.hadRecall ? "recalled" : "no recalls"; const proj = s.project ? " · " + s.project : ""; + const events = s.eventCount != null ? s.eventCount : s.memorySearchCount; return '
    • ' + esc(s.sessionId.slice(0, 8)) + '… · ' + - esc(s.endedAt) + proj + ' · ' + esc(recall) + ' · searches: ' + esc(s.memorySearchCount) + '
    • '; + esc(s.endedAt) + proj + ' · ' + esc(recall) + ' · events: ' + esc(events) + ''; }).join(""); ul.querySelectorAll("li[data-session]").forEach(li => { li.addEventListener("click", () => post("openSession", { sessionId: li.dataset.session })); @@ -390,6 +426,21 @@ export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.U }); } + function renderGoals(goals) { + const ul = document.getElementById("goals-list"); + if (!goals || goals.length === 0) { + ul.innerHTML = '
    • No goals yet.
    • '; + return; + } + ul.innerHTML = goals.map(g => + '
    • ' + + '' + esc(g.status || "open") + ' ' + + '' + esc(g.text || g.goalId) + '' + + (g.owner ? ' · ' + esc(g.owner) + '' : '') + + '
    • ' + ).join(""); + } + function renderSkills(skills) { const root = document.getElementById("skills-list"); if (!skills || skills.length === 0) { @@ -705,6 +756,13 @@ export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.U renderSkills(msg.skills); } break; + case "goals": + if (msg.loggedOut) { + document.getElementById("goals-list").innerHTML = '
    • ' + esc(msg.message || "Log in required.") + '
    • '; + } else { + renderGoals(msg.goals); + } + break; case "impact": state.impact = msg.impact; const caveat = document.getElementById("impact-caveat"); @@ -733,6 +791,20 @@ export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.U const btn = document.getElementById("btn-graph-build"); if (btn) btn.disabled = !!msg.inProgress; document.getElementById("graph-build-result").textContent = msg.message || ""; + const inlineBtn = document.getElementById("btn-graph-build-inline"); + if (inlineBtn) { + inlineBtn.disabled = !!msg.inProgress; + inlineBtn.textContent = msg.inProgress ? "Building graph…" : "Build graph now"; + } + const emptyBanner = document.getElementById("graph-empty-banner"); + if (emptyBanner && (msg.inProgress || !msg.ok)) { + emptyBanner.hidden = false; + emptyBanner.textContent = msg.message || ""; + } + } + if (msg.target === "goals") { + const el = document.getElementById("goal-add-result"); + if (el) el.textContent = msg.message || ""; } if (msg.target === "skillSync") { document.getElementById("skill-sync-result").textContent = msg.message || "";