From c489e2bee276ef5497869c0bbdebff1c3215cc17 Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:45:19 +0800 Subject: [PATCH 01/12] chore(core): add project identity types --- packages/core/src/types.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a63b342..0b256e6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -48,6 +48,28 @@ export interface Session { projectDisplayName: string } +export type ProjectIdentityKind = + | 'git_remote' + | 'git_common_dir' + | 'manifest_path' + | 'path' + | 'loose' + +export interface ProjectIdentity { + kind: ProjectIdentityKind + key: string // normalized origin URL / abs path / 'loose' + displayName: string +} + +export interface ProjectGroup { + identityKind: ProjectIdentityKind + identityKey: string + displayName: string + sources: SessionSource[] // unique sources contributing + sessionCount: number + lastSessionAt: string | null +} + export interface Message { id: number sessionId: number From 2a36658279ebec7616f74e93ac60a1aab29c447e Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:50:04 +0800 Subject: [PATCH 02/12] feat(core): add project identity computation Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/projects/identity.test.ts | 99 +++++++++++++ packages/core/src/projects/identity.ts | 150 ++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 packages/core/src/projects/identity.test.ts create mode 100644 packages/core/src/projects/identity.ts diff --git a/packages/core/src/projects/identity.test.ts b/packages/core/src/projects/identity.test.ts new file mode 100644 index 0000000..6b194a9 --- /dev/null +++ b/packages/core/src/projects/identity.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { computeIdentity, normalizeGitRemote } from './identity.js' + +const noFs = { + exists: () => false, + readText: () => null, + spawn: () => ({ stdout: '', exitCode: 1 }), +} + +describe('normalizeGitRemote', () => { + it('strips .git and lowercases host', () => { + expect(normalizeGitRemote('git@github.com:Foo/Bar.git')) + .toBe('github.com/foo/bar') + expect(normalizeGitRemote('https://GitHub.com/foo/bar')) + .toBe('github.com/foo/bar') + expect(normalizeGitRemote('https://user:pass@github.com/foo/bar.git')) + .toBe('github.com/foo/bar') + }) + it('returns null for unparseable input', () => { + expect(normalizeGitRemote('')).toBeNull() + expect(normalizeGitRemote('not-a-url')).toBeNull() + }) +}) + +describe('computeIdentity', () => { + it('returns loose for null cwd', () => { + const id = computeIdentity(null, noFs) + expect(id.kind).toBe('loose') + expect(id.key).toBe('loose') + }) + + it('returns loose for home dir', () => { + expect(computeIdentity('/Users/chen', noFs).kind).toBe('loose') + expect(computeIdentity('/Users/chen/Desktop', noFs).kind).toBe('loose') + expect(computeIdentity('/Users/chen/Downloads', noFs).kind).toBe('loose') + expect(computeIdentity('/tmp', noFs).kind).toBe('loose') + }) + + it('uses git remote when available', () => { + const fs = { + exists: (p: string) => p.endsWith('/.git'), + readText: () => null, + spawn: (cmd: string, args: string[]) => { + if (args.includes('remote.origin.url')) + return { stdout: 'git@github.com:spool-lab/spool.git\n', exitCode: 0 } + if (args.includes('--show-toplevel')) + return { stdout: '/Users/chen/Code/spool\n', exitCode: 0 } + if (args.includes('--git-common-dir')) + return { stdout: '/Users/chen/Code/spool/.git\n', exitCode: 0 } + return { stdout: '', exitCode: 1 } + }, + } + const id = computeIdentity('/Users/chen/Code/spool', fs) + expect(id.kind).toBe('git_remote') + expect(id.key).toBe('github.com/spool-lab/spool') + }) + + it('falls back to git common-dir when no remote', () => { + const fs = { + exists: (p: string) => p.endsWith('/.git'), + readText: () => null, + spawn: (cmd: string, args: string[]) => { + if (args.includes('remote.origin.url')) + return { stdout: '', exitCode: 1 } + if (args.includes('--git-common-dir')) + return { stdout: '/Users/chen/local-only/.git\n', exitCode: 0 } + return { stdout: '', exitCode: 1 } + }, + } + const id = computeIdentity('/Users/chen/local-only', fs) + expect(id.kind).toBe('git_common_dir') + expect(id.key).toBe('/Users/chen/local-only/.git') + }) + + it('falls back to manifest dir when no git', () => { + const fs = { + exists: (p: string) => p === '/Users/chen/proj/package.json', + readText: (p: string) => + p.endsWith('package.json') ? '{"name":"my-proj"}' : null, + spawn: () => ({ stdout: '', exitCode: 1 }), + } + const id = computeIdentity('/Users/chen/proj/src', fs) + expect(id.kind).toBe('manifest_path') + expect(id.key).toBe('/Users/chen/proj') + expect(id.displayName).toBe('my-proj') + }) + + it('uses bare path as last resort', () => { + const fs = { + exists: () => false, + readText: () => null, + spawn: () => ({ stdout: '', exitCode: 1 }), + } + const id = computeIdentity('/Users/chen/scratch/notes', fs) + expect(id.kind).toBe('path') + expect(id.key).toBe('/Users/chen/scratch/notes') + expect(id.displayName).toBe('notes') + }) +}) diff --git a/packages/core/src/projects/identity.ts b/packages/core/src/projects/identity.ts new file mode 100644 index 0000000..316146f --- /dev/null +++ b/packages/core/src/projects/identity.ts @@ -0,0 +1,150 @@ +import { homedir } from 'node:os' +import { dirname, basename, join } from 'node:path' +import type { ProjectIdentity, ProjectIdentityKind } from '../types.js' + +export interface IdentityFs { + exists(path: string): boolean + readText(path: string): string | null + spawn(cmd: string, args: string[], opts: { cwd: string }): { stdout: string; exitCode: number } +} + +const MANIFESTS = [ + 'package.json', 'Cargo.toml', 'pyproject.toml', + 'go.mod', 'Gemfile', 'pom.xml', 'build.gradle', +] as const + +const LOOSE_DIRS = new Set([ + '/tmp', '/private/tmp', +]) +const LOOSE_HOME_DIRS = ['Desktop', 'Downloads', 'Documents'] + +export function normalizeGitRemote(url: string): string | null { + if (!url) return null + let s = url.trim().replace(/\.git$/, '') + // git@host:owner/repo → host/owner/repo + const sshMatch = s.match(/^[^@]+@([^:]+):(.+)$/) + if (sshMatch) s = `${sshMatch[1]}/${sshMatch[2]}` + // strip protocol + credentials + s = s.replace(/^[a-z]+:\/\/(?:[^@/]*@)?/i, '') + if (!s.includes('/')) return null + return s.toLowerCase() +} + +export function computeIdentity(cwd: string | null, fs: IdentityFs): ProjectIdentity { + if (!cwd) return loose() + + const home = homedir() + if (cwd === home || LOOSE_DIRS.has(cwd)) return loose() + if (LOOSE_HOME_DIRS.some(d => cwd === join(home, d))) return loose() + + // 1. git + const gitRoot = findGitRoot(cwd, fs) + if (gitRoot) { + const remote = fs.spawn('git', ['config', '--get', 'remote.origin.url'], { cwd: gitRoot }) + if (remote.exitCode === 0) { + const norm = normalizeGitRemote(remote.stdout.trim()) + if (norm) { + return { + kind: 'git_remote', + key: norm, + displayName: deriveDisplayName({ kind: 'git_remote', key: norm, gitRoot, fs }), + } + } + } + const common = fs.spawn('git', ['rev-parse', '--git-common-dir'], { cwd: gitRoot }) + if (common.exitCode === 0) { + const key = common.stdout.trim() + return { + kind: 'git_common_dir', + key, + displayName: deriveDisplayName({ kind: 'git_common_dir', key, gitRoot, fs }), + } + } + } + + // 2. manifest + const manifestDir = findManifestDir(cwd, fs) + if (manifestDir) { + return { + kind: 'manifest_path', + key: manifestDir, + displayName: deriveDisplayName({ kind: 'manifest_path', key: manifestDir, fs }), + } + } + + // 3. path + return { + kind: 'path', + key: cwd, + displayName: basename(cwd) || cwd, + } +} + +function loose(): ProjectIdentity { + return { kind: 'loose', key: 'loose', displayName: 'Loose' } +} + +function findGitRoot(start: string, fs: IdentityFs): string | null { + let cur = start + while (cur && cur !== '/') { + if (fs.exists(join(cur, '.git'))) return cur + const parent = dirname(cur) + if (parent === cur) break + cur = parent + } + return null +} + +function findManifestDir(start: string, fs: IdentityFs): string | null { + let cur = start + while (cur && cur !== '/') { + for (const m of MANIFESTS) { + if (fs.exists(join(cur, m))) return cur + } + const parent = dirname(cur) + if (parent === cur) break + cur = parent + } + return null +} + +interface DisplayNameInput { + kind: ProjectIdentityKind + key: string + gitRoot?: string + fs: IdentityFs +} + +function deriveDisplayName(input: DisplayNameInput): string { + // Try manifest name first + const dir = input.gitRoot ?? (input.kind === 'manifest_path' ? input.key : null) + if (dir) { + for (const m of MANIFESTS) { + const p = join(dir, m) + if (input.fs.exists(p)) { + const name = parseManifestName(m, input.fs.readText(p) ?? '') + if (name) return name + } + } + } + // git remote → last segment + if (input.kind === 'git_remote') { + const parts = input.key.split('/') + return parts[parts.length - 1] || input.key + } + // common-dir or path → containing dir name + if (input.gitRoot) return basename(input.gitRoot) + return basename(input.key) || input.key +} + +function parseManifestName(file: string, text: string): string | null { + if (!text) return null + if (file === 'package.json' || file === 'Cargo.toml' || file === 'pyproject.toml') { + // Cheap regex: find name = "x" or "name": "x" + const m = + text.match(/"name"\s*:\s*"([^"]+)"/) || + text.match(/^\s*name\s*=\s*"([^"]+)"/m) + if (m) return m[1] + } + return null +} From 4d712638c8d65c1255e01306ef49c491e21f42a3 Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:54:15 +0800 Subject: [PATCH 03/12] fix(core): absolutize git_common_dir + tidy deriveDisplayName Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/projects/identity.test.ts | 19 +++++++++++++++++-- packages/core/src/projects/identity.ts | 21 ++++++++++++++------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/core/src/projects/identity.test.ts b/packages/core/src/projects/identity.test.ts index 6b194a9..75ca214 100644 --- a/packages/core/src/projects/identity.test.ts +++ b/packages/core/src/projects/identity.test.ts @@ -43,8 +43,6 @@ describe('computeIdentity', () => { spawn: (cmd: string, args: string[]) => { if (args.includes('remote.origin.url')) return { stdout: 'git@github.com:spool-lab/spool.git\n', exitCode: 0 } - if (args.includes('--show-toplevel')) - return { stdout: '/Users/chen/Code/spool\n', exitCode: 0 } if (args.includes('--git-common-dir')) return { stdout: '/Users/chen/Code/spool/.git\n', exitCode: 0 } return { stdout: '', exitCode: 1 } @@ -96,4 +94,21 @@ describe('computeIdentity', () => { expect(id.key).toBe('/Users/chen/scratch/notes') expect(id.displayName).toBe('notes') }) + + it('absolutizes a relative git_common_dir against gitRoot', () => { + const fs = { + exists: (p: string) => p.endsWith('/.git'), + readText: () => null, + spawn: (cmd: string, args: string[]) => { + if (args.includes('remote.origin.url')) + return { stdout: '', exitCode: 1 } + if (args.includes('--git-common-dir')) + return { stdout: '../shared.git\n', exitCode: 0 } // relative + return { stdout: '', exitCode: 1 } + }, + } + const id = computeIdentity('/Users/chen/Code/spool-wt', fs) + expect(id.kind).toBe('git_common_dir') + expect(id.key).toBe('/Users/chen/Code/shared.git') // resolved up one level + }) }) diff --git a/packages/core/src/projects/identity.ts b/packages/core/src/projects/identity.ts index 316146f..b460222 100644 --- a/packages/core/src/projects/identity.ts +++ b/packages/core/src/projects/identity.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os' -import { dirname, basename, join } from 'node:path' +import { dirname, basename, join, isAbsolute, resolve } from 'node:path' import type { ProjectIdentity, ProjectIdentityKind } from '../types.js' export interface IdentityFs { @@ -13,6 +13,10 @@ const MANIFESTS = [ 'go.mod', 'Gemfile', 'pom.xml', 'build.gradle', ] as const +const PARSEABLE_MANIFESTS = [ + 'package.json', 'Cargo.toml', 'pyproject.toml', +] as const + const LOOSE_DIRS = new Set([ '/tmp', '/private/tmp', ]) @@ -53,11 +57,14 @@ export function computeIdentity(cwd: string | null, fs: IdentityFs): ProjectIden } const common = fs.spawn('git', ['rev-parse', '--git-common-dir'], { cwd: gitRoot }) if (common.exitCode === 0) { - const key = common.stdout.trim() - return { - kind: 'git_common_dir', - key, - displayName: deriveDisplayName({ kind: 'git_common_dir', key, gitRoot, fs }), + const raw = common.stdout.trim() + if (raw) { + const key = isAbsolute(raw) ? raw : resolve(gitRoot, raw) + return { + kind: 'git_common_dir', + key, + displayName: deriveDisplayName({ kind: 'git_common_dir', key, gitRoot, fs }), + } } } } @@ -119,7 +126,7 @@ function deriveDisplayName(input: DisplayNameInput): string { // Try manifest name first const dir = input.gitRoot ?? (input.kind === 'manifest_path' ? input.key : null) if (dir) { - for (const m of MANIFESTS) { + for (const m of PARSEABLE_MANIFESTS) { const p = join(dir, m) if (input.fs.exists(p)) { const name = parseManifestName(m, input.fs.readText(p) ?? '') From 7d9352fa0aa0f236a4a2e64a1f4b908bff8be68c Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:56:36 +0800 Subject: [PATCH 04/12] refactor(core): extract fallback display name util --- .../core/src/projects/display-name.test.ts | 18 ++++++++++++++++++ packages/core/src/projects/display-name.ts | 6 ++++++ packages/core/src/projects/identity.ts | 9 +++++---- 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/projects/display-name.test.ts create mode 100644 packages/core/src/projects/display-name.ts diff --git a/packages/core/src/projects/display-name.test.ts b/packages/core/src/projects/display-name.test.ts new file mode 100644 index 0000000..b8b3744 --- /dev/null +++ b/packages/core/src/projects/display-name.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' +import { fallbackDisplayName } from './display-name.js' + +describe('fallbackDisplayName', () => { + it('returns last path segment', () => { + expect(fallbackDisplayName('/Users/chen/Code/spool')).toBe('spool') + expect(fallbackDisplayName('/var/folders/scratch')).toBe('scratch') + }) + it('handles trailing slash', () => { + expect(fallbackDisplayName('/Users/chen/Code/spool/')).toBe('spool') + }) + it('returns "(root)" for /', () => { + expect(fallbackDisplayName('/')).toBe('(root)') + }) + it('returns last segment of slash-bearing strings even non-paths', () => { + expect(fallbackDisplayName('github.com/foo/bar')).toBe('bar') + }) +}) diff --git a/packages/core/src/projects/display-name.ts b/packages/core/src/projects/display-name.ts new file mode 100644 index 0000000..e9a0689 --- /dev/null +++ b/packages/core/src/projects/display-name.ts @@ -0,0 +1,6 @@ +export function fallbackDisplayName(input: string): string { + if (input === '/') return '(root)' + const trimmed = input.replace(/\/+$/, '') + const parts = trimmed.split('/').filter(Boolean) + return parts[parts.length - 1] ?? trimmed +} diff --git a/packages/core/src/projects/identity.ts b/packages/core/src/projects/identity.ts index b460222..358db46 100644 --- a/packages/core/src/projects/identity.ts +++ b/packages/core/src/projects/identity.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os' -import { dirname, basename, join, isAbsolute, resolve } from 'node:path' +import { dirname, join, isAbsolute, resolve } from 'node:path' import type { ProjectIdentity, ProjectIdentityKind } from '../types.js' +import { fallbackDisplayName } from './display-name.js' export interface IdentityFs { exists(path: string): boolean @@ -83,7 +84,7 @@ export function computeIdentity(cwd: string | null, fs: IdentityFs): ProjectIden return { kind: 'path', key: cwd, - displayName: basename(cwd) || cwd, + displayName: fallbackDisplayName(cwd), } } @@ -140,8 +141,8 @@ function deriveDisplayName(input: DisplayNameInput): string { return parts[parts.length - 1] || input.key } // common-dir or path → containing dir name - if (input.gitRoot) return basename(input.gitRoot) - return basename(input.key) || input.key + if (input.gitRoot) return fallbackDisplayName(input.gitRoot) + return fallbackDisplayName(input.key) } function parseManifestName(file: string, text: string): string | null { From 677dc0dd0b95e30aff64098c922be5aef2a17a0a Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:00:27 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(core):=20migration=20v6=20=E2=80=94?= =?UTF-8?q?=20project=20identity=20columns=20+=20groups=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/db/db.ts | 34 ++++++++++++++++++- packages/core/src/db/migration-v5.test.ts | 6 ++-- packages/core/src/db/migration-v6.test.ts | 40 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/db/migration-v6.test.ts diff --git a/packages/core/src/db/db.ts b/packages/core/src/db/db.ts index c9dd87d..7185ac7 100644 --- a/packages/core/src/db/db.ts +++ b/packages/core/src/db/db.ts @@ -42,7 +42,7 @@ export function getDBSize(): number { } } -function runMigrations(db: Database.Database): void { +export function runMigrations(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS sources ( id INTEGER PRIMARY KEY, @@ -318,6 +318,38 @@ function runMigrations(db: Database.Database): void { db.pragma('user_version = 5') } + if (version < 6) { + db.transaction(() => { + db.exec(` + ALTER TABLE projects ADD COLUMN identity_kind TEXT; + ALTER TABLE projects ADD COLUMN identity_key TEXT; + CREATE INDEX IF NOT EXISTS idx_projects_identity + ON projects (identity_kind, identity_key); + + CREATE VIEW IF NOT EXISTS project_groups_v AS + SELECT + p.identity_kind, + p.identity_key, + MIN(p.display_name) AS display_name, + GROUP_CONCAT(DISTINCT s.name) AS sources_csv, + COALESCE(SUM(c.session_count),0) AS session_count, + MAX(c.last_session_at) AS last_session_at + FROM projects p + JOIN sources s ON s.id = p.source_id + LEFT JOIN ( + SELECT project_id, + COUNT(*) AS session_count, + MAX(started_at) AS last_session_at + FROM sessions + GROUP BY project_id + ) c ON c.project_id = p.id + WHERE p.identity_kind IS NOT NULL + GROUP BY p.identity_kind, p.identity_key; + `) + })() + db.pragma('user_version = 6') + } + rebuildFtsTableIfEmpty(db, 'messages', 'messages_fts_trigram') rebuildFtsTableIfEmpty(db, 'session_search', 'session_search_fts') rebuildFtsTableIfEmpty(db, 'session_search', 'session_search_fts_trigram') diff --git a/packages/core/src/db/migration-v5.test.ts b/packages/core/src/db/migration-v5.test.ts index ea098e0..f777666 100644 --- a/packages/core/src/db/migration-v5.test.ts +++ b/packages/core/src/db/migration-v5.test.ts @@ -148,8 +148,8 @@ describe('migration v5 (connector subsystem removal)', () => { expect(dbModule.wasNewDb()).toBe(false) expect(dbModule.getInitialUserVersion()).toBe(4) - // user_version bumped to 5 - expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBe(5) + // user_version bumped past 5 (current head is 6) + expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBeGreaterThanOrEqual(5) // Connector tables and FTS gone const tablesAfter = db.prepare("SELECT name FROM sqlite_master WHERE type='table' OR type='virtual'").all() as Array<{ name: string }> @@ -189,7 +189,7 @@ describe('migration v5 (connector subsystem removal)', () => { expect(dbModule.wasNewDb()).toBe(true) expect(dbModule.getInitialUserVersion()).toBe(0) - expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBe(5) + expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBeGreaterThanOrEqual(5) // Stars exists with narrow CHECK expect(() => db.prepare("INSERT INTO stars (item_type, item_uuid) VALUES ('capture', 'x')").run()).toThrow() diff --git a/packages/core/src/db/migration-v6.test.ts b/packages/core/src/db/migration-v6.test.ts new file mode 100644 index 0000000..90156e8 --- /dev/null +++ b/packages/core/src/db/migration-v6.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import Database from 'better-sqlite3' +import { runMigrations } from './db.js' + +describe('migration v6', () => { + let db: Database.Database + beforeEach(() => { db = new Database(':memory:') }) + afterEach(() => db.close()) + + it('adds identity_kind / identity_key columns to projects', () => { + runMigrations(db) + const cols = db.prepare(`PRAGMA table_info(projects)`).all() as { name: string }[] + expect(cols.map(c => c.name)).toEqual( + expect.arrayContaining(['identity_kind', 'identity_key']) + ) + const v = db.pragma('user_version') as Array<{ user_version: number }> + expect(v[0].user_version).toBe(6) + }) + + it('creates project_groups_v view', () => { + runMigrations(db) + const v = db.prepare( + `SELECT name FROM sqlite_master WHERE type='view' AND name='project_groups_v'` + ).get() + expect(v).toBeDefined() + }) + + it('view groups rows with same identity across sources', () => { + runMigrations(db) + db.exec(` + INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key) + VALUES + (1, 'spool-c', '/Users/chen/Code/spool', 'spool', 'git_remote', 'github.com/spool-lab/spool'), + (2, 'spool-x', '/Users/chen/Code/spool', 'spool', 'git_remote', 'github.com/spool-lab/spool'); + `) + const groups = db.prepare(`SELECT * FROM project_groups_v`).all() as Array<{ identity_key: string }> + expect(groups).toHaveLength(1) + expect(groups[0].identity_key).toBe('github.com/spool-lab/spool') + }) +}) From 9b7fb1e0816f36e7b9f2f6ddc11eaa757af013eb Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:06:26 +0800 Subject: [PATCH 06/12] feat(core): persist project identity on insert --- packages/core/src/db/queries.test.ts | 34 ++++++++++++++++++++++++++++ packages/core/src/db/queries.ts | 7 +++--- packages/core/src/db/stars.test.ts | 1 + packages/core/src/sync/syncer.ts | 5 +++- 4 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/db/queries.test.ts diff --git a/packages/core/src/db/queries.test.ts b/packages/core/src/db/queries.test.ts new file mode 100644 index 0000000..05edac2 --- /dev/null +++ b/packages/core/src/db/queries.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import Database from 'better-sqlite3' +import { runMigrations } from './db.js' +import { getOrCreateProject } from './queries.js' + +describe('getOrCreateProject (with identity)', () => { + let db: Database.Database + beforeEach(() => { + db = new Database(':memory:') + runMigrations(db) + // sources are seeded by runMigrations; do not re-insert + }) + + it('persists identity_kind and identity_key', () => { + getOrCreateProject(db, 1, 'spool-c', '/Users/chen/Code/spool', 'spool', { + identityKind: 'git_remote', + identityKey: 'github.com/spool-lab/spool', + }) + const row = db.prepare(`SELECT identity_kind, identity_key FROM projects WHERE slug = ?`) + .get('spool-c') as { identity_kind: string; identity_key: string } + expect(row.identity_kind).toBe('git_remote') + expect(row.identity_key).toBe('github.com/spool-lab/spool') + }) + + it('does not duplicate on second call (same source_id, slug)', () => { + const id1 = getOrCreateProject(db, 1, 'spool-c', '/Users/chen/Code/spool', 'spool', { + identityKind: 'git_remote', identityKey: 'github.com/spool-lab/spool', + }) + const id2 = getOrCreateProject(db, 1, 'spool-c', '/Users/chen/Code/spool', 'spool', { + identityKind: 'git_remote', identityKey: 'github.com/spool-lab/spool', + }) + expect(id1).toBe(id2) + }) +}) diff --git a/packages/core/src/db/queries.ts b/packages/core/src/db/queries.ts index fbc76ca..b691642 100644 --- a/packages/core/src/db/queries.ts +++ b/packages/core/src/db/queries.ts @@ -1,5 +1,5 @@ import type Database from 'better-sqlite3' -import type { Session, Message, FragmentResult, StatusInfo, SearchMatchType, SessionSource, StarKind, StarredItem } from '../types.js' +import type { Session, Message, FragmentResult, StatusInfo, SearchMatchType, SessionSource, StarKind, StarredItem, ProjectIdentityKind } from '../types.js' import { DB_PATH, getDBSize } from './db.js' import { buildSearchPlan, canUseSessionSearchFts, getNaturalSearchPhrase, getNaturalSearchTerms, selectFtsTableKind, shouldUseSessionFallback } from './search-query.js' @@ -9,6 +9,7 @@ export function getOrCreateProject( slug: string, displayPath: string, displayName: string, + identity: { identityKind: ProjectIdentityKind; identityKey: string }, ): number { const existing = db .prepare('SELECT id FROM projects WHERE source_id = ? AND slug = ?') @@ -18,9 +19,9 @@ export function getOrCreateProject( const result = db .prepare( - 'INSERT INTO projects (source_id, slug, display_path, display_name) VALUES (?, ?, ?, ?)', + 'INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key) VALUES (?, ?, ?, ?, ?, ?)', ) - .run(sourceId, slug, displayPath, displayName) + .run(sourceId, slug, displayPath, displayName, identity.identityKind, identity.identityKey) return Number(result.lastInsertRowid) } diff --git a/packages/core/src/db/stars.test.ts b/packages/core/src/db/stars.test.ts index b8836b5..27d9ce6 100644 --- a/packages/core/src/db/stars.test.ts +++ b/packages/core/src/db/stars.test.ts @@ -167,6 +167,7 @@ async function loadInto(_spoolDir: string) { projectDisplay.toLowerCase().replace(/\s+/g, '-'), `/fake/${projectDisplay}`, projectDisplay, + { identityKind: 'path', identityKey: `/fake/${projectDisplay}` }, ) queryModule.upsertSession(db, { projectId, diff --git a/packages/core/src/sync/syncer.ts b/packages/core/src/sync/syncer.ts index 0270804..394958a 100644 --- a/packages/core/src/sync/syncer.ts +++ b/packages/core/src/sync/syncer.ts @@ -169,7 +169,10 @@ export class Syncer { const sourceId = getSourceId(this.db, source) const { slug, displayPath, displayName } = resolveProject(filePath, source, parsed.cwd) - const projectId = getOrCreateProject(this.db, sourceId, slug, displayPath, displayName) + const projectId = getOrCreateProject(this.db, sourceId, slug, displayPath, displayName, { + identityKind: 'path', + identityKey: displayPath, + }) const isNew = existingMtime === null const hasToolUse = parsed.messages.some(m => m.toolNames.length > 0) From 75478e681ccaf884125758a98181b71b792ba9c4 Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:08:53 +0800 Subject: [PATCH 07/12] feat(core): wire project identity into syncer Replace placeholder identity arg in syncFile with real computeIdentity() call backed by a new realFs adapter (existsSync/readFileSync/spawnSync). Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/projects/fs.ts | 14 ++++++++++++++ packages/core/src/sync/syncer.ts | 12 ++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/projects/fs.ts diff --git a/packages/core/src/projects/fs.ts b/packages/core/src/projects/fs.ts new file mode 100644 index 0000000..94fa3a8 --- /dev/null +++ b/packages/core/src/projects/fs.ts @@ -0,0 +1,14 @@ +import { existsSync, readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import type { IdentityFs } from './identity.js' + +export const realFs: IdentityFs = { + exists: existsSync, + readText: (p) => { + try { return readFileSync(p, 'utf8') } catch { return null } + }, + spawn: (cmd, args, opts) => { + const r = spawnSync(cmd, args, { cwd: opts.cwd, encoding: 'utf8' }) + return { stdout: r.stdout ?? '', exitCode: r.status ?? 1 } + }, +} diff --git a/packages/core/src/sync/syncer.ts b/packages/core/src/sync/syncer.ts index 394958a..3f17b61 100644 --- a/packages/core/src/sync/syncer.ts +++ b/packages/core/src/sync/syncer.ts @@ -18,6 +18,8 @@ import { insertMessages, } from '../db/queries.js' import type { ParsedMessage, SyncResult } from '../types.js' +import { computeIdentity } from '../projects/identity.js' +import { realFs } from '../projects/fs.js' export interface SyncProgressEvent { phase: 'scanning' | 'syncing' | 'indexing' | 'done' @@ -169,10 +171,12 @@ export class Syncer { const sourceId = getSourceId(this.db, source) const { slug, displayPath, displayName } = resolveProject(filePath, source, parsed.cwd) - const projectId = getOrCreateProject(this.db, sourceId, slug, displayPath, displayName, { - identityKind: 'path', - identityKey: displayPath, - }) + const identity = computeIdentity(parsed.cwd || null, realFs) + const projectId = getOrCreateProject( + this.db, sourceId, slug, displayPath, + identity.displayName || displayName, + { identityKind: identity.kind, identityKey: identity.key }, + ) const isNew = existingMtime === null const hasToolUse = parsed.messages.some(m => m.toolNames.length > 0) From 49c4f9b16e819cc1780b7e1ca943ef4064193c3c Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:11:20 +0800 Subject: [PATCH 08/12] feat(core): backfill identity for pre-v6 project rows --- packages/core/src/db/db.ts | 21 ++++++++++++ packages/core/src/db/migration-v6.test.ts | 41 ++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/core/src/db/db.ts b/packages/core/src/db/db.ts index 7185ac7..16b3232 100644 --- a/packages/core/src/db/db.ts +++ b/packages/core/src/db/db.ts @@ -2,6 +2,8 @@ import Database from 'better-sqlite3' import { homedir } from 'node:os' import { join } from 'node:path' import { existsSync, mkdirSync, statSync } from 'node:fs' +import { computeIdentity, type IdentityFs } from '../projects/identity.js' +import { realFs } from '../projects/fs.js' export const SPOOL_DIR = process.env['SPOOL_DATA_DIR'] ?? join(homedir(), '.spool') export const DB_PATH = join(SPOOL_DIR, 'spool.db') @@ -348,6 +350,7 @@ export function runMigrations(db: Database.Database): void { `) })() db.pragma('user_version = 6') + backfillProjectIdentities(db, realFs) } rebuildFtsTableIfEmpty(db, 'messages', 'messages_fts_trigram') @@ -355,6 +358,24 @@ export function runMigrations(db: Database.Database): void { rebuildFtsTableIfEmpty(db, 'session_search', 'session_search_fts_trigram') } +export function backfillProjectIdentities(db: Database.Database, fs: IdentityFs) { + const rows = db.prepare( + `SELECT id, display_path FROM projects WHERE identity_kind IS NULL` + ).all() as { id: number; display_path: string }[] + if (rows.length === 0) return + const update = db.prepare( + `UPDATE projects + SET identity_kind = ?, identity_key = ?, display_name = COALESCE(?, display_name) + WHERE id = ?`, + ) + db.transaction(() => { + for (const r of rows) { + const id = computeIdentity(r.display_path, fs) + update.run(id.kind, id.key, id.displayName, r.id) + } + })() +} + function rebuildFtsTableIfEmpty( db: Database.Database, contentTable: 'messages' | 'session_search', diff --git a/packages/core/src/db/migration-v6.test.ts b/packages/core/src/db/migration-v6.test.ts index 90156e8..f273b37 100644 --- a/packages/core/src/db/migration-v6.test.ts +++ b/packages/core/src/db/migration-v6.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import Database from 'better-sqlite3' -import { runMigrations } from './db.js' +import { runMigrations, backfillProjectIdentities } from './db.js' +import type { IdentityFs } from '../projects/identity.js' + +const stubFs: IdentityFs = { + exists: () => false, + readText: () => null, + spawn: () => ({ stdout: '', exitCode: 1 }), +} describe('migration v6', () => { let db: Database.Database @@ -38,3 +45,35 @@ describe('migration v6', () => { expect(groups[0].identity_key).toBe('github.com/spool-lab/spool') }) }) + +describe('backfillProjectIdentities', () => { + let db: Database.Database + beforeEach(() => { db = new Database(':memory:') }) + afterEach(() => db.close()) + + it('backfills identity for rows with NULL identity_kind', () => { + runMigrations(db) + db.exec(` + INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key) + VALUES (1, 'old-row', '/Users/chen/scratch/notes', 'notes', NULL, NULL); + `) + backfillProjectIdentities(db, stubFs) + const r = db.prepare(`SELECT identity_kind, identity_key FROM projects WHERE slug = ?`) + .get('old-row') as { identity_kind: string; identity_key: string } + expect(r.identity_kind).toBe('path') + expect(r.identity_key).toBe('/Users/chen/scratch/notes') + }) + + it('skips rows that already have identity', () => { + runMigrations(db) + db.exec(` + INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key) + VALUES (1, 'has-id', '/x', 'x', 'git_remote', 'github.com/foo/bar'); + `) + backfillProjectIdentities(db, stubFs) + const r = db.prepare(`SELECT identity_kind, identity_key FROM projects WHERE slug = ?`) + .get('has-id') as { identity_kind: string; identity_key: string } + expect(r.identity_kind).toBe('git_remote') // unchanged + expect(r.identity_key).toBe('github.com/foo/bar') // unchanged + }) +}) From d869cba1f90c3aad2e74ad81cb4ddcbf5aed8567 Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:12:30 +0800 Subject: [PATCH 09/12] feat(core): listProjectGroups query --- packages/core/src/projects/groups.test.ts | 54 +++++++++++++++++++++++ packages/core/src/projects/groups.ts | 29 ++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 packages/core/src/projects/groups.test.ts create mode 100644 packages/core/src/projects/groups.ts diff --git a/packages/core/src/projects/groups.test.ts b/packages/core/src/projects/groups.test.ts new file mode 100644 index 0000000..7ae3434 --- /dev/null +++ b/packages/core/src/projects/groups.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import Database from 'better-sqlite3' +import { runMigrations } from '../db/db.js' +import { listProjectGroups } from './groups.js' + +describe('listProjectGroups', () => { + let db: Database.Database + beforeEach(() => { + db = new Database(':memory:') + runMigrations(db) + // sources auto-seeded by runMigrations + }) + + it('returns empty array when no projects', () => { + expect(listProjectGroups(db)).toEqual([]) + }) + + it('aggregates same-identity rows across sources', () => { + db.exec(` + INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key) + VALUES + (1,'spool-c','/Users/chen/Code/spool','spool','git_remote','github.com/spool-lab/spool'), + (2,'spool-x','/Users/chen/Code/spool','spool','git_remote','github.com/spool-lab/spool'); + INSERT INTO sessions (project_id, source_id, session_uuid, file_path, title, started_at, ended_at, message_count, has_tool_use, raw_file_mtime) + VALUES + (1,1,'u1','/p1','t','2026-04-28T10:00:00Z','2026-04-28T10:30:00Z',5,0,'2026-04-28T10:30:00Z'), + (2,2,'u2','/p2','t','2026-04-27T10:00:00Z','2026-04-27T10:30:00Z',3,0,'2026-04-27T10:30:00Z'); + `) + const groups = listProjectGroups(db) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ + identityKey: 'github.com/spool-lab/spool', + sources: expect.arrayContaining(['claude', 'codex']), + sessionCount: 2, + }) + }) + + it('orders by lastSessionAt desc, loose last', () => { + db.exec(` + INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key) + VALUES + (1,'a','/Users/chen/Code/a','a','path','/Users/chen/Code/a'), + (1,'b','/Users/chen/Code/b','b','path','/Users/chen/Code/b'), + (1,'l','','Loose','loose','loose'); + INSERT INTO sessions (project_id, source_id, session_uuid, file_path, title, started_at, ended_at, message_count, has_tool_use, raw_file_mtime) + VALUES + (1,1,'u-a','/pa','t','2026-04-28T10:00:00Z','2026-04-28T11:00:00Z',1,0,'2026-04-28T11:00:00Z'), + (2,1,'u-b','/pb','t','2026-04-26T10:00:00Z','2026-04-26T11:00:00Z',1,0,'2026-04-26T11:00:00Z'), + (3,1,'u-l','/pl','t','2026-04-29T10:00:00Z','2026-04-29T11:00:00Z',1,0,'2026-04-29T11:00:00Z'); + `) + const groups = listProjectGroups(db) + expect(groups.map(g => g.identityKind)).toEqual(['path', 'path', 'loose']) + }) +}) diff --git a/packages/core/src/projects/groups.ts b/packages/core/src/projects/groups.ts new file mode 100644 index 0000000..7164058 --- /dev/null +++ b/packages/core/src/projects/groups.ts @@ -0,0 +1,29 @@ +import type Database from 'better-sqlite3' +import type { ProjectGroup, ProjectIdentityKind, SessionSource } from '../types.js' + +export function listProjectGroups(db: Database.Database): ProjectGroup[] { + const rows = db.prepare(` + SELECT identity_kind, identity_key, display_name, sources_csv, + session_count, last_session_at + FROM project_groups_v + ORDER BY + CASE identity_kind WHEN 'loose' THEN 1 ELSE 0 END, + last_session_at IS NULL, + last_session_at DESC + `).all() as Array<{ + identity_kind: ProjectIdentityKind + identity_key: string + display_name: string + sources_csv: string | null + session_count: number + last_session_at: string | null + }> + return rows.map(r => ({ + identityKind: r.identity_kind, + identityKey: r.identity_key, + displayName: r.display_name, + sources: (r.sources_csv ?? '').split(',').filter(Boolean) as SessionSource[], + sessionCount: r.session_count, + lastSessionAt: r.last_session_at, + })) +} From 6c9b2b4ddb204bfcbe5f54e41958633824752be3 Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:14:17 +0800 Subject: [PATCH 10/12] test(core): e2e project identity unification --- packages/core/src/projects/e2e.test.ts | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/core/src/projects/e2e.test.ts diff --git a/packages/core/src/projects/e2e.test.ts b/packages/core/src/projects/e2e.test.ts new file mode 100644 index 0000000..769811e --- /dev/null +++ b/packages/core/src/projects/e2e.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import Database from 'better-sqlite3' +import { runMigrations } from '../db/db.js' +import { getOrCreateProject } from '../db/queries.js' +import { listProjectGroups } from './groups.js' + +describe('project identity e2e', () => { + let db: Database.Database + beforeEach(() => { + db = new Database(':memory:') + runMigrations(db) + // sources auto-seeded by runMigrations (claude=1, codex=2, gemini=3) + // We need a 4th source for the test ('chatgpt') if it's not seeded. + // Verify what's auto-seeded: + // SELECT id, name FROM sources; + // If chatgpt isn't there, insert it. + db.exec(`INSERT OR IGNORE INTO sources (name, base_path) VALUES ('chatgpt', '/tmp/chatgpt');`) + }) + + it('unifies same repo across multiple sources into one group', () => { + const id = { identityKind: 'git_remote' as const, identityKey: 'github.com/spool-lab/spool' } + + // Look up source IDs by name (don't hardcode positional integers) + const claudeId = (db.prepare(`SELECT id FROM sources WHERE name = 'claude'`).get() as { id: number }).id + const codexId = (db.prepare(`SELECT id FROM sources WHERE name = 'codex'`).get() as { id: number }).id + const chatgptId = (db.prepare(`SELECT id FROM sources WHERE name = 'chatgpt'`).get() as { id: number }).id + + getOrCreateProject(db, claudeId, 'spool-claude', '/Users/chen/Code/spool', 'spool', id) + getOrCreateProject(db, codexId, 'spool-codex', '/Users/chen/Code/spool', 'spool', id) + getOrCreateProject(db, chatgptId, 'spool-chatgpt', '/Users/chen/Code/spool', 'spool', id) + + const groups = listProjectGroups(db) + expect(groups).toHaveLength(1) + expect(groups[0].sources.sort()).toEqual(['chatgpt', 'claude', 'codex']) + }) + + it('keeps two unrelated path-based projects separate', () => { + const claudeId = (db.prepare(`SELECT id FROM sources WHERE name = 'claude'`).get() as { id: number }).id + getOrCreateProject(db, claudeId, 'a', '/Users/chen/playground/a', 'a', + { identityKind: 'path', identityKey: '/Users/chen/playground/a' }) + getOrCreateProject(db, claudeId, 'b', '/Users/chen/playground/b', 'b', + { identityKind: 'path', identityKey: '/Users/chen/playground/b' }) + expect(listProjectGroups(db)).toHaveLength(2) + }) +}) From 1ca3e66323587f74600930a75813fc413909f9ef Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:16:09 +0800 Subject: [PATCH 11/12] fix(core): guard regex match group for strict tsc --- packages/core/src/projects/identity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/projects/identity.ts b/packages/core/src/projects/identity.ts index 358db46..51d169b 100644 --- a/packages/core/src/projects/identity.ts +++ b/packages/core/src/projects/identity.ts @@ -152,7 +152,7 @@ function parseManifestName(file: string, text: string): string | null { const m = text.match(/"name"\s*:\s*"([^"]+)"/) || text.match(/^\s*name\s*=\s*"([^"]+)"/m) - if (m) return m[1] + if (m && m[1]) return m[1] } return null } From af065835452771922b571aaa8772fa8e56b45935 Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:23:36 +0800 Subject: [PATCH 12/12] fix(core): spawn timeout + homedir-portable identity tests --- packages/core/src/projects/fs.ts | 4 +++- packages/core/src/projects/identity.test.ts | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/src/projects/fs.ts b/packages/core/src/projects/fs.ts index 94fa3a8..7d8fcf4 100644 --- a/packages/core/src/projects/fs.ts +++ b/packages/core/src/projects/fs.ts @@ -8,7 +8,9 @@ export const realFs: IdentityFs = { try { return readFileSync(p, 'utf8') } catch { return null } }, spawn: (cmd, args, opts) => { - const r = spawnSync(cmd, args, { cwd: opts.cwd, encoding: 'utf8' }) + // 5s timeout: a hung `git config` (e.g. ssh-agent passphrase prompt, + // network-mounted repo) must not block session indexing. + const r = spawnSync(cmd, args, { cwd: opts.cwd, encoding: 'utf8', timeout: 5000 }) return { stdout: r.stdout ?? '', exitCode: r.status ?? 1 } }, } diff --git a/packages/core/src/projects/identity.test.ts b/packages/core/src/projects/identity.test.ts index 75ca214..f6a965f 100644 --- a/packages/core/src/projects/identity.test.ts +++ b/packages/core/src/projects/identity.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { homedir } from 'node:os' import { computeIdentity, normalizeGitRemote } from './identity.js' const noFs = { @@ -30,9 +31,10 @@ describe('computeIdentity', () => { }) it('returns loose for home dir', () => { - expect(computeIdentity('/Users/chen', noFs).kind).toBe('loose') - expect(computeIdentity('/Users/chen/Desktop', noFs).kind).toBe('loose') - expect(computeIdentity('/Users/chen/Downloads', noFs).kind).toBe('loose') + const home = homedir() + expect(computeIdentity(home, noFs).kind).toBe('loose') + expect(computeIdentity(`${home}/Desktop`, noFs).kind).toBe('loose') + expect(computeIdentity(`${home}/Downloads`, noFs).kind).toBe('loose') expect(computeIdentity('/tmp', noFs).kind).toBe('loose') })