fix(indexer): derive project_path from message cwd instead of slug decoding

The old slug-to-path conversion (replace hyphens with slashes) was
  lossy and wrong for paths containing hyphens. Now infers project_path
  from the most-frequent observed cwd across session messages, falling
  back to slug decoding only when no cwd data exists. Adds
  refreshSessionProjectPaths() to backfill existing sessions on rebuild.
This commit is contained in:
tommy0103
2026-06-10 03:15:32 +08:00
parent 807e5141fa
commit 10b51d8878
3 changed files with 52 additions and 9 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ Project-like fields are distinct:
- `sessions.project`: stored Claude Code project slug. - `sessions.project`: stored Claude Code project slug.
- `memories.project`: stored project slug copied onto registered memory records. - `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. - `messages.cwd`: working directory at message time.
- helper `project`: SQL `LIKE` over `sessions.project`, not exact membership. - helper `project`: SQL `LIKE` over `sessions.project`, not exact membership.
+7 -6
View File
@@ -17,8 +17,8 @@ One row per Claude Code session.
CREATE TABLE sessions ( CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- session UUID (matches JSONL filename) id TEXT PRIMARY KEY, -- session UUID (matches JSONL filename)
title TEXT, -- AI-generated session title (may be NULL) title TEXT, -- AI-generated session title (may be NULL)
project TEXT, -- project slug (hyphenated path, e.g. "Users-tomiya-Code-quiet-zero") project TEXT, -- Claude project slug (e.g. "-Users-tomiya-Code-quiet-zero")
project_path TEXT, -- reconstructed filesystem path (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 started_at TEXT, -- ISO 8601 timestamp of first message
ended_at TEXT, -- ISO 8601 timestamp of last message ended_at TEXT, -- ISO 8601 timestamp of last message
git_branch TEXT, -- git branch active during session (if any) 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) | | `opts.cwd` | `string` | Filter by working directory (supports LIKE) |
**Scope note:** `sessions.project` is the stored Claude Code project slug, **Scope note:** `sessions.project` is the stored Claude Code project slug,
`sessions.project_path` is the reconstructed absolute project path, and `sessions.project_path` is the absolute session path derived from message `cwd`
`messages.cwd` is the working directory at message time. Helper `project` when available, and `messages.cwd` is the working directory at message time.
filters are fuzzy `LIKE` filters over `sessions.project`. For exact project Helper `project` filters are fuzzy `LIKE` filters over `sessions.project`. For
membership, use `sql()` with `s.project = ?` or `s.project_path = ?`. exact project membership, use `sql()` with `s.project = ?` or
`s.project_path = ?`.
**Returns:** `Array<{ message, session, rank, context }>` where `context` is the **Returns:** `Array<{ message, session, rank, context }>` where `context` is the
6 nearest messages by timestamp in the same session. It is temporal neighbor 6 nearest messages by timestamp in the same session. It is temporal neighbor
+44 -2
View File
@@ -3,6 +3,29 @@ import { CLAUDE_DIR, openDb, trunc, truncJson, extractText, filePath, isDir, rea
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl'); 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() { function discoverJsonlFiles() {
const files = []; const files = [];
if (!fs.existsSync(PROJECTS_DIR)) return files; if (!fs.existsSync(PROJECTS_DIR)) return files;
@@ -74,6 +97,7 @@ function indexJsonl(db, fi) {
version: existing?.version || null, version: existing?.version || null,
title: existing?.title || null, title: existing?.title || null,
n: existing?.message_count || 0, n: existing?.message_count || 0,
cwds: [],
}; };
let lineNum = 0; let lineNum = 0;
@@ -101,6 +125,7 @@ function indexJsonl(db, fi) {
if (obj.gitBranch) sm.git_branch = obj.gitBranch; if (obj.gitBranch) sm.git_branch = obj.gitBranch;
if (obj.version) sm.version = obj.version; if (obj.version) sm.version = obj.version;
sm.n++; sm.n++;
if (!fi.isSubagent && obj.cwd) sm.cwds.push(obj.cwd);
const msg = obj.message || {}; const msg = obj.message || {};
const text = extractText(msg.content); const text = extractText(msg.content);
@@ -132,12 +157,28 @@ function indexJsonl(db, fi) {
}); });
if (!fi.isSubagent) { 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.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); 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) { function indexSubagentMeta(db, fi) {
if (!fi.isSubagent) return; if (!fi.isSubagent) return;
const mp = fi.path.replace('.jsonl', '.meta.json'); const mp = fi.path.replace('.jsonl', '.meta.json');
@@ -226,6 +267,7 @@ function buildIndex({ force = false } = {}) {
db.exec('BEGIN'); db.exec('BEGIN');
try { try {
indexWorkflows(db); indexWorkflows(db);
refreshSessionProjectPaths(db);
indexHistory(db); indexHistory(db);
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')"); 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()); 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(); db.close();
} }
export { buildIndex }; export { buildIndex, inferProjectPath, refreshSessionProjectPaths };