diff --git a/references/retrieval-semantics.md b/references/retrieval-semantics.md index 117095d..ad88d57 100644 --- a/references/retrieval-semantics.md +++ b/references/retrieval-semantics.md @@ -30,7 +30,7 @@ Project-like fields are distinct: - `sessions.project`: stored Claude Code project slug. - `memories.project`: stored project slug copied onto registered memory records. -- `sessions.project_path`: reconstructed absolute project path. +- `sessions.project_path`: absolute session path derived from message `cwd` when available; slug decoding is only a fallback. - `messages.cwd`: working directory at message time. - helper `project`: SQL `LIKE` over `sessions.project`, not exact membership. diff --git a/references/schema.md b/references/schema.md index f74c921..517fb04 100644 --- a/references/schema.md +++ b/references/schema.md @@ -17,8 +17,8 @@ One row per Claude Code session. CREATE TABLE sessions ( id TEXT PRIMARY KEY, -- session UUID (matches JSONL filename) title TEXT, -- AI-generated session title (may be NULL) - project TEXT, -- project slug (hyphenated path, e.g. "Users-tomiya-Code-quiet-zero") - project_path TEXT, -- reconstructed filesystem path (e.g. "/Users/tomiya/Code/quiet-zero") + project TEXT, -- Claude project slug (e.g. "-Users-tomiya-Code-quiet-zero") + project_path TEXT, -- absolute session cwd-derived path, with slug fallback (e.g. "/Users/tomiya/Code/quiet-zero") started_at TEXT, -- ISO 8601 timestamp of first message ended_at TEXT, -- ISO 8601 timestamp of last message git_branch TEXT, -- git branch active during session (if any) @@ -242,10 +242,11 @@ Full-text search across all message text using FTS5. | `opts.cwd` | `string` | Filter by working directory (supports LIKE) | **Scope note:** `sessions.project` is the stored Claude Code project slug, -`sessions.project_path` is the reconstructed absolute project path, and -`messages.cwd` is the working directory at message time. Helper `project` -filters are fuzzy `LIKE` filters over `sessions.project`. For exact project -membership, use `sql()` with `s.project = ?` or `s.project_path = ?`. +`sessions.project_path` is the absolute session path derived from message `cwd` +when available, and `messages.cwd` is the working directory at message time. +Helper `project` filters are fuzzy `LIKE` filters over `sessions.project`. For +exact project membership, use `sql()` with `s.project = ?` or +`s.project_path = ?`. **Returns:** `Array<{ message, session, rank, context }>` where `context` is the 6 nearest messages by timestamp in the same session. It is temporal neighbor diff --git a/scripts/indexer.mjs b/scripts/indexer.mjs index 6dae658..c787e63 100644 --- a/scripts/indexer.mjs +++ b/scripts/indexer.mjs @@ -3,6 +3,29 @@ import { CLAUDE_DIR, openDb, trunc, truncJson, extractText, filePath, isDir, rea const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl'); +function legacyProjectPathFromSlug(project) { + if (!project) return null; + return '/' + project.replace(/-/g, '/').replace(/^\//, ''); +} + +function normalizeObservedCwd(cwd) { + if (typeof cwd !== 'string' || !cwd.trim() || !path.isAbsolute(cwd)) return null; + return path.normalize(cwd); +} + +function inferProjectPath(project, observedCwds = []) { + const byPath = new Map(); + for (const cwd of observedCwds) { + const normalized = normalizeObservedCwd(cwd); + if (!normalized) continue; + const current = byPath.get(normalized) || { path: normalized, count: 0, first: byPath.size }; + current.count++; + byPath.set(normalized, current); + } + const best = [...byPath.values()].sort((a, b) => b.count - a.count || a.first - b.first)[0]; + return best?.path || legacyProjectPathFromSlug(project); +} + function discoverJsonlFiles() { const files = []; if (!fs.existsSync(PROJECTS_DIR)) return files; @@ -74,6 +97,7 @@ function indexJsonl(db, fi) { version: existing?.version || null, title: existing?.title || null, n: existing?.message_count || 0, + cwds: [], }; let lineNum = 0; @@ -101,6 +125,7 @@ function indexJsonl(db, fi) { if (obj.gitBranch) sm.git_branch = obj.gitBranch; if (obj.version) sm.version = obj.version; sm.n++; + if (!fi.isSubagent && obj.cwd) sm.cwds.push(obj.cwd); const msg = obj.message || {}; const text = extractText(msg.content); @@ -132,12 +157,28 @@ function indexJsonl(db, fi) { }); if (!fi.isSubagent) { - const pp = '/' + fi.project.replace(/-/g, '/').replace(/^\//, ''); + const pp = inferProjectPath(fi.project, sm.cwds); ins.ses.run(fi.sessionId, sm.title, fi.project, pp, sm.started_at, sm.ended_at, sm.git_branch, sm.version, sm.n, fi.path); } ins.idx.run(fi.path, mt, lineNum); } +function refreshSessionProjectPaths(db) { + const sessions = db.prepare('SELECT id, project FROM sessions').all(); + const cwdStmt = db.prepare(` + SELECT cwd + FROM messages + WHERE session_id = ? AND cwd IS NOT NULL AND cwd != '' + ORDER BY timestamp IS NULL, timestamp + `); + const update = db.prepare('UPDATE sessions SET project_path = ? WHERE id = ?'); + for (const session of sessions) { + const cwds = cwdStmt.all(session.id).map(row => row.cwd); + const projectPath = inferProjectPath(session.project, cwds); + if (projectPath) update.run(projectPath, session.id); + } +} + function indexSubagentMeta(db, fi) { if (!fi.isSubagent) return; const mp = fi.path.replace('.jsonl', '.meta.json'); @@ -226,6 +267,7 @@ function buildIndex({ force = false } = {}) { db.exec('BEGIN'); try { indexWorkflows(db); + refreshSessionProjectPaths(db); indexHistory(db); db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')"); db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now()); @@ -237,4 +279,4 @@ function buildIndex({ force = false } = {}) { db.close(); } -export { buildIndex }; +export { buildIndex, inferProjectPath, refreshSessionProjectPaths };