linksee-memory 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ import { readdirSync, statSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
  import { openDb, runMigrations } from '../db/migrate.js';
14
- import { parseSessionFile, projectNameFromCwd } from '../lib/session-parser.js';
14
+ import { parseSessionFile, detectProjectName } from '../lib/session-parser.js';
15
15
  import { extractSession } from '../lib/session-extractor.js';
16
16
  const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
17
17
  function usage() {
@@ -76,7 +76,7 @@ async function main() {
76
76
  console.log(`[skip] empty or invalid session: ${sessionFile}`);
77
77
  return;
78
78
  }
79
- const projectName = projectNameFromCwd(parsed.project_cwd) || 'unknown';
79
+ const projectName = detectProjectName(parsed) || 'unknown';
80
80
  const result = extractSession(parsed, projectName);
81
81
  if (dryRun) {
82
82
  console.log(`[dry] session ${result.session_id.slice(0, 8)} (${projectName}): ${result.memories.length} memories, ${result.file_edits.length} file_edits`);
@@ -86,9 +86,10 @@ async function main() {
86
86
  return;
87
87
  // Idempotent: wipe any prior data for THIS session before re-inserting
88
88
  const wiped = wipeSession(db, result.session_id);
89
- // Resolve project entity
89
+ // Resolve project entity (canonical_key = project:<name>, so same project across
90
+ // different sessions/cwds collapses to one entity)
90
91
  let projectEntityId;
91
- const canonicalKey = parsed.project_cwd;
92
+ const canonicalKey = `project:${projectName.toLowerCase()}`;
92
93
  const existing = db.prepare('SELECT id FROM entities WHERE canonical_key = ? OR (kind = ? AND LOWER(name) = LOWER(?))').get(canonicalKey, 'project', projectName);
93
94
  if (existing) {
94
95
  projectEntityId = existing.id;
@@ -169,7 +170,11 @@ async function main() {
169
170
  continue;
170
171
  }
171
172
  agg.projects++;
172
- const dirName = projectDir.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'unknown';
173
+ // Decode Claude Code's project-dir encoding: "C--Users-HP-KanseiLINK" → "KanseiLINK"
174
+ // (the encoding replaces `/` with `--`, so the last segment is the actual project name)
175
+ const rawDirName = projectDir.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'unknown';
176
+ const decodedParts = rawDirName.split('--').filter(Boolean);
177
+ const dirName = decodedParts.length > 1 ? decodedParts[decodedParts.length - 1] : rawDirName;
173
178
  console.log(`\n=== Project: ${dirName} (${files.length} session files) ===`);
174
179
  let projectEntityId = null;
175
180
  for (const f of files) {
@@ -186,7 +191,10 @@ async function main() {
186
191
  agg.sessions_skipped++;
187
192
  continue;
188
193
  }
189
- const projectName = projectNameFromCwd(parsed.project_cwd) || dirName;
194
+ // Detect project name from actual file edits (majority vote), falling back
195
+ // to cwd-based detection. Previously used cwd alone, which collapsed many
196
+ // distinct projects under one entity (e.g. "Card_Navi" for everything).
197
+ const projectName = detectProjectName(parsed) || dirName;
190
198
  const result = extractSession(parsed, projectName);
191
199
  agg.sessions_parsed++;
192
200
  agg.memories_planned += result.memories.length;
@@ -196,17 +204,16 @@ async function main() {
196
204
  }
197
205
  if (!db)
198
206
  continue;
199
- // Ensure entity exists (once per project)
200
- if (projectEntityId === null) {
201
- const canonicalKey = parsed.project_cwd;
202
- const existing = db.prepare('SELECT id FROM entities WHERE canonical_key = ? OR (kind = ? AND LOWER(name) = LOWER(?))').get(canonicalKey, 'project', projectName);
203
- if (existing) {
204
- projectEntityId = existing.id;
205
- }
206
- else {
207
- const ins = db.prepare('INSERT INTO entities (kind, name, canonical_key) VALUES (?, ?, ?)').run('project', projectName, canonicalKey);
208
- projectEntityId = Number(ins.lastInsertRowid);
209
- }
207
+ // Resolve entity per-SESSION (not per-project-dir) since each session may
208
+ // belong to a different project based on its file ops.
209
+ const canonicalKey = `project:${projectName.toLowerCase()}`;
210
+ const existing = db.prepare('SELECT id FROM entities WHERE canonical_key = ? OR (kind = ? AND LOWER(name) = LOWER(?))').get(canonicalKey, 'project', projectName);
211
+ if (existing) {
212
+ projectEntityId = existing.id;
213
+ }
214
+ else {
215
+ const ins = db.prepare('INSERT INTO entities (kind, name, canonical_key) VALUES (?, ?, ?)').run('project', projectName, canonicalKey);
216
+ projectEntityId = Number(ins.lastInsertRowid);
210
217
  }
211
218
  // Idempotent: wipe any prior data for THIS session before re-inserting (Phase B)
212
219
  wipeSession(db, result.session_id);
@@ -38,3 +38,6 @@ export declare function isPastedExternalContent(text: string): boolean;
38
38
  export declare function isAutomatedSession(firstUserText: string): boolean;
39
39
  export declare function parseSessionFile(jsonlPath: string): ParsedSession | null;
40
40
  export declare function projectNameFromCwd(cwd: string): string;
41
+ export declare function projectNameFromFilePath(filePath: string): string | null;
42
+ export declare function projectNameFromFileOps(file_ops: ParsedSession['file_ops']): string | null;
43
+ export declare function detectProjectName(parsed: ParsedSession): string;
@@ -300,4 +300,97 @@ export function projectNameFromCwd(cwd) {
300
300
  return 'unknown';
301
301
  return cwd.replace(/\\/g, '/').replace(/\/$/, '').split('/').filter(Boolean).pop() ?? 'unknown';
302
302
  }
303
+ // ─── Improved entity detection ───────────────────────────────────────────
304
+ // Problem: projectNameFromCwd() uses the session's starting cwd, but Claude Code
305
+ // sessions often edit files across many projects. Everything ends up tagged with
306
+ // the one cwd name (e.g. "Card_Navi"), destroying recall precision.
307
+ //
308
+ // Solution: inspect file_ops, extract the project name from each file path,
309
+ // and take the majority vote. Fall back to cwd if no useful file ops exist.
310
+ // Directories we never treat as "project" — they're just user home conventions
311
+ // or Claude Code internal dirs.
312
+ const SKIP_DIR_SEGMENTS = new Set([
313
+ '.claude', '.config', '.cache', '.local', '.vscode', '.git', '.ssh', '.npm',
314
+ 'Downloads', 'Documents', 'Desktop', 'Pictures', 'Music', 'Videos',
315
+ 'node_modules', 'tmp', 'temp', 'AppData', 'Library',
316
+ 'OneDrive', 'Dropbox', 'Google Drive',
317
+ 'Users', 'home', 'HP', // part of the user home path itself
318
+ // Claude Code internal subdirs (appear when Claude reads its own state files):
319
+ 'projects', 'commands', 'skills', 'worktrees', 'hooks', 'agents', 'plugins',
320
+ 'settings', 'memory', 'todos', 'shell-snapshots', 'ide',
321
+ ]);
322
+ // User-home-like prefixes we strip before looking for the project segment.
323
+ // Add drive-letter-agnostic patterns that match Windows / macOS / Linux / WSL.
324
+ const HOME_PATTERNS = [
325
+ /^[A-Za-z]:\/Users\/[^\/]+\//i, // C:/Users/HP/...
326
+ /^\/Users\/[^\/]+\//, // /Users/HP/... (macOS)
327
+ /^\/home\/[^\/]+\//, // /home/HP/... (Linux)
328
+ /^\/mnt\/[a-z]\/Users\/[^\/]+\//i, // /mnt/c/Users/HP/... (WSL)
329
+ /^[A-Za-z]:\/Set up company\//i, // D:/Set up company/... (Michie-specific)
330
+ ];
331
+ function stripHomePrefix(normalized) {
332
+ for (const p of HOME_PATTERNS) {
333
+ if (p.test(normalized))
334
+ return normalized.replace(p, '');
335
+ }
336
+ return normalized;
337
+ }
338
+ // Extract a project name from a single file path.
339
+ // Returns null if the path doesn't contain a meaningful project segment.
340
+ export function projectNameFromFilePath(filePath) {
341
+ if (!filePath)
342
+ return null;
343
+ const normalized = filePath.replace(/\\/g, '/');
344
+ // Paths inside Claude Code's own state dir are internal — never a real project signal.
345
+ // e.g. C:/Users/HP/.claude/projects/C--Users-HP-KanseiLINK/abc.jsonl
346
+ if (/\/\.claude\//.test(normalized))
347
+ return null;
348
+ const rest = stripHomePrefix(normalized);
349
+ if (rest === normalized)
350
+ return null; // no home prefix matched, can't safely extract
351
+ const segments = rest.split('/').filter(Boolean);
352
+ for (const seg of segments) {
353
+ if (SKIP_DIR_SEGMENTS.has(seg))
354
+ continue;
355
+ if (seg.startsWith('.'))
356
+ continue;
357
+ // Skip single-file-like segments (have extension) — we want a directory name
358
+ if (/\.[a-z0-9]{1,5}$/i.test(seg))
359
+ continue;
360
+ // Skip Claude Code's project-dir encoding (e.g. "C--Users-HP-Xxx")
361
+ if (/^[A-Z]--/.test(seg))
362
+ continue;
363
+ return seg;
364
+ }
365
+ return null;
366
+ }
367
+ // Aggregate file_ops into a single project name by majority vote.
368
+ // Returns null if no file_op yields a usable name.
369
+ export function projectNameFromFileOps(file_ops) {
370
+ if (!file_ops || file_ops.length === 0)
371
+ return null;
372
+ const counts = new Map();
373
+ for (const op of file_ops) {
374
+ const name = projectNameFromFilePath(op.path);
375
+ if (name)
376
+ counts.set(name, (counts.get(name) ?? 0) + 1);
377
+ }
378
+ if (counts.size === 0)
379
+ return null;
380
+ let bestName = null;
381
+ let bestCount = 0;
382
+ for (const [name, count] of counts) {
383
+ if (count > bestCount) {
384
+ bestName = name;
385
+ bestCount = count;
386
+ }
387
+ }
388
+ return bestName;
389
+ }
390
+ // Combined detector: prefers file_ops majority, falls back to cwd-based.
391
+ // This is the function import-sessions.ts should call.
392
+ export function detectProjectName(parsed) {
393
+ return (projectNameFromFileOps(parsed.file_ops) ??
394
+ projectNameFromCwd(parsed.project_cwd));
395
+ }
303
396
  //# sourceMappingURL=session-parser.js.map
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
+ "mcpName": "io.github.michielinksee/linksee-memory",
4
5
  "description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
5
6
  "type": "module",
6
7
  "bin": {