engrm 0.4.8 → 0.4.10

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.
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
  // src/cli.ts
21
21
  import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, statSync } from "fs";
22
22
  import { hostname as hostname2, homedir as homedir4, networkInterfaces as networkInterfaces2 } from "os";
23
- import { dirname as dirname4, join as join7 } from "path";
23
+ import { dirname as dirname5, join as join7 } from "path";
24
24
  import { createHash as createHash3 } from "crypto";
25
25
  import { fileURLToPath as fileURLToPath4 } from "url";
26
26
 
@@ -689,6 +689,11 @@ import { createHash as createHash2 } from "node:crypto";
689
689
  var IS_BUN = typeof globalThis.Bun !== "undefined";
690
690
  function openDatabase(dbPath) {
691
691
  if (IS_BUN) {
692
+ if (process.platform === "darwin") {
693
+ try {
694
+ return openNodeDatabase(dbPath);
695
+ } catch {}
696
+ }
692
697
  return openBunDatabase(dbPath);
693
698
  }
694
699
  return openNodeDatabase(dbPath);
@@ -805,6 +810,15 @@ class MemDatabase {
805
810
  }
806
811
  return row;
807
812
  }
813
+ reassignObservationProject(observationId, projectId) {
814
+ const existing = this.getObservationById(observationId);
815
+ if (!existing)
816
+ return false;
817
+ if (existing.project_id === projectId)
818
+ return true;
819
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
820
+ return true;
821
+ }
808
822
  getObservationById(id) {
809
823
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
810
824
  }
@@ -938,8 +952,13 @@ class MemDatabase {
938
952
  }
939
953
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
940
954
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
941
- if (existing)
955
+ if (existing) {
956
+ if (existing.project_id === null && projectId !== null) {
957
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
958
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
959
+ }
942
960
  return existing;
961
+ }
943
962
  const now = Math.floor(Date.now() / 1000);
944
963
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
945
964
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
@@ -2108,7 +2127,7 @@ function registerAll() {
2108
2127
 
2109
2128
  // src/packs/loader.ts
2110
2129
  import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync } from "node:fs";
2111
- import { join as join4, dirname as dirname2 } from "node:path";
2130
+ import { join as join4, dirname as dirname3 } from "node:path";
2112
2131
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2113
2132
 
2114
2133
  // src/tools/save.ts
@@ -2436,7 +2455,7 @@ function looksMeaningful(value) {
2436
2455
  // src/storage/projects.ts
2437
2456
  import { execSync } from "node:child_process";
2438
2457
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
2439
- import { basename, join as join3 } from "node:path";
2458
+ import { basename, dirname as dirname2, join as join3, resolve } from "node:path";
2440
2459
  function normaliseGitRemoteUrl(remoteUrl) {
2441
2460
  let url = remoteUrl.trim();
2442
2461
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -2490,6 +2509,19 @@ function getGitRemoteUrl(directory) {
2490
2509
  }
2491
2510
  }
2492
2511
  }
2512
+ function getGitTopLevel(directory) {
2513
+ try {
2514
+ const root = execSync("git rev-parse --show-toplevel", {
2515
+ cwd: directory,
2516
+ encoding: "utf-8",
2517
+ timeout: 5000,
2518
+ stdio: ["pipe", "pipe", "pipe"]
2519
+ }).trim();
2520
+ return root || null;
2521
+ } catch {
2522
+ return null;
2523
+ }
2524
+ }
2493
2525
  function readProjectConfigFile(directory) {
2494
2526
  const configPath = join3(directory, ".engrm.json");
2495
2527
  if (!existsSync3(configPath))
@@ -2512,11 +2544,12 @@ function detectProject(directory) {
2512
2544
  const remoteUrl = getGitRemoteUrl(directory);
2513
2545
  if (remoteUrl) {
2514
2546
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
2547
+ const repoRoot = getGitTopLevel(directory) ?? directory;
2515
2548
  return {
2516
2549
  canonical_id: canonicalId,
2517
2550
  name: projectNameFromCanonicalId(canonicalId),
2518
2551
  remote_url: remoteUrl,
2519
- local_path: directory
2552
+ local_path: repoRoot
2520
2553
  };
2521
2554
  }
2522
2555
  const configFile = readProjectConfigFile(directory);
@@ -2536,6 +2569,32 @@ function detectProject(directory) {
2536
2569
  local_path: directory
2537
2570
  };
2538
2571
  }
2572
+ function detectProjectForPath(filePath, fallbackCwd) {
2573
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
2574
+ const candidateDir = existsSync3(absolutePath) && !absolutePath.endsWith("/") ? dirname2(absolutePath) : dirname2(absolutePath);
2575
+ const detected = detectProject(candidateDir);
2576
+ if (detected.canonical_id.startsWith("local/"))
2577
+ return null;
2578
+ return detected;
2579
+ }
2580
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
2581
+ const counts = new Map;
2582
+ for (const rawPath of paths) {
2583
+ if (!rawPath || !rawPath.trim())
2584
+ continue;
2585
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
2586
+ if (!detected)
2587
+ continue;
2588
+ const existing = counts.get(detected.canonical_id);
2589
+ if (existing) {
2590
+ existing.count += 1;
2591
+ } else {
2592
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
2593
+ }
2594
+ }
2595
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
2596
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
2597
+ }
2539
2598
 
2540
2599
  // src/embeddings/embedder.ts
2541
2600
  var _available = null;
@@ -2814,7 +2873,8 @@ async function saveObservation(db, config, input) {
2814
2873
  return { success: false, reason: "Title is required" };
2815
2874
  }
2816
2875
  const cwd = input.cwd ?? process.cwd();
2817
- const detected = detectProject(cwd);
2876
+ const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
2877
+ const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
2818
2878
  const project = db.upsertProject({
2819
2879
  canonical_id: detected.canonical_id,
2820
2880
  name: detected.name,
@@ -2941,7 +3001,7 @@ function toRelativePath(filePath, projectRoot) {
2941
3001
 
2942
3002
  // src/packs/loader.ts
2943
3003
  function getPacksDir() {
2944
- const thisDir = dirname2(fileURLToPath2(import.meta.url));
3004
+ const thisDir = dirname3(fileURLToPath2(import.meta.url));
2945
3005
  return join4(thisDir, "..", "..", "packs");
2946
3006
  }
2947
3007
  function listPacks() {
@@ -2991,10 +3051,10 @@ async function installPack(db, config, packName, cwd) {
2991
3051
 
2992
3052
  // src/sentinel/rules.ts
2993
3053
  import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "node:fs";
2994
- import { join as join5, dirname as dirname3 } from "node:path";
3054
+ import { join as join5, dirname as dirname4 } from "node:path";
2995
3055
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2996
3056
  function getRulePacksDir() {
2997
- const thisDir = dirname3(fileURLToPath3(import.meta.url));
3057
+ const thisDir = dirname4(fileURLToPath3(import.meta.url));
2998
3058
  return join5(thisDir, "rule-packs");
2999
3059
  }
3000
3060
  function listRulePacks() {
@@ -3065,10 +3125,18 @@ function getCaptureStatus(db, input = {}) {
3065
3125
  const codexMcpRegistered = codexConfigContent.includes("[mcp_servers.engrm]") || codexConfigContent.includes(`[mcp_servers.${LEGACY_CODEX_SERVER_NAME2}]`);
3066
3126
  const codexHooksRegistered = codexHooksContent.includes('"SessionStart"') && codexHooksContent.includes('"Stop"');
3067
3127
  let claudeHookCount = 0;
3128
+ let claudeSessionStartHook = false;
3129
+ let claudeUserPromptHook = false;
3130
+ let claudePostToolHook = false;
3131
+ let claudeStopHook = false;
3068
3132
  if (claudeHooksRegistered) {
3069
3133
  try {
3070
3134
  const settings = JSON.parse(claudeSettingsContent);
3071
3135
  const hooks = settings?.hooks ?? {};
3136
+ claudeSessionStartHook = Array.isArray(hooks["SessionStart"]);
3137
+ claudeUserPromptHook = Array.isArray(hooks["UserPromptSubmit"]);
3138
+ claudePostToolHook = Array.isArray(hooks["PostToolUse"]);
3139
+ claudeStopHook = Array.isArray(hooks["Stop"]);
3072
3140
  for (const entries of Object.values(hooks)) {
3073
3141
  if (!Array.isArray(entries))
3074
3142
  continue;
@@ -3081,6 +3149,13 @@ function getCaptureStatus(db, input = {}) {
3081
3149
  }
3082
3150
  } catch {}
3083
3151
  }
3152
+ let codexSessionStartHook = false;
3153
+ let codexStopHook = false;
3154
+ try {
3155
+ const hooks = codexHooksContent ? JSON.parse(codexHooksContent)?.hooks ?? {} : {};
3156
+ codexSessionStartHook = Array.isArray(hooks["SessionStart"]);
3157
+ codexStopHook = Array.isArray(hooks["Stop"]);
3158
+ } catch {}
3084
3159
  const visibilityClause = input.user_id ? " AND user_id = ?" : "";
3085
3160
  const params = input.user_id ? [sinceEpoch, input.user_id] : [sinceEpoch];
3086
3161
  const recentUserPrompts = db.db.query(`SELECT COUNT(*) as count FROM user_prompts
@@ -3095,6 +3170,17 @@ function getCaptureStatus(db, input = {}) {
3095
3170
  EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
3096
3171
  OR EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
3097
3172
  )`).get(...params)?.count ?? 0;
3173
+ const recentSessionsWithPartialCapture = db.db.query(`SELECT COUNT(*) as count
3174
+ FROM sessions s
3175
+ WHERE COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) >= ?
3176
+ ${input.user_id ? "AND s.user_id = ?" : ""}
3177
+ AND (
3178
+ (s.tool_calls_count > 0 AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id))
3179
+ OR (
3180
+ EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
3181
+ AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
3182
+ )
3183
+ )`).get(...params)?.count ?? 0;
3098
3184
  const latestPromptEpoch = db.db.query(`SELECT created_at_epoch FROM user_prompts
3099
3185
  WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
3100
3186
  ORDER BY created_at_epoch DESC, prompt_number DESC
@@ -3103,6 +3189,9 @@ function getCaptureStatus(db, input = {}) {
3103
3189
  WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
3104
3190
  ORDER BY created_at_epoch DESC, id DESC
3105
3191
  LIMIT 1`).get(...input.user_id ? [input.user_id] : [])?.created_at_epoch ?? null;
3192
+ const latestPostToolHookEpoch = parseNullableInt(db.getSyncState("hook_post_tool_last_seen_epoch"));
3193
+ const latestPostToolParseStatus = db.getSyncState("hook_post_tool_last_parse_status");
3194
+ const latestPostToolName = db.getSyncState("hook_post_tool_last_tool_name");
3106
3195
  const schemaVersion = getSchemaVersion(db.db);
3107
3196
  return {
3108
3197
  schema_version: schemaVersion,
@@ -3110,16 +3199,33 @@ function getCaptureStatus(db, input = {}) {
3110
3199
  claude_mcp_registered: claudeMcpRegistered,
3111
3200
  claude_hooks_registered: claudeHooksRegistered,
3112
3201
  claude_hook_count: claudeHookCount,
3202
+ claude_session_start_hook: claudeSessionStartHook,
3203
+ claude_user_prompt_hook: claudeUserPromptHook,
3204
+ claude_post_tool_hook: claudePostToolHook,
3205
+ claude_stop_hook: claudeStopHook,
3113
3206
  codex_mcp_registered: codexMcpRegistered,
3114
3207
  codex_hooks_registered: codexHooksRegistered,
3208
+ codex_session_start_hook: codexSessionStartHook,
3209
+ codex_stop_hook: codexStopHook,
3210
+ codex_raw_chronology_supported: false,
3115
3211
  recent_user_prompts: recentUserPrompts,
3116
3212
  recent_tool_events: recentToolEvents,
3117
3213
  recent_sessions_with_raw_capture: recentSessionsWithRawCapture,
3214
+ recent_sessions_with_partial_capture: recentSessionsWithPartialCapture,
3118
3215
  latest_prompt_epoch: latestPromptEpoch,
3119
3216
  latest_tool_event_epoch: latestToolEventEpoch,
3217
+ latest_post_tool_hook_epoch: latestPostToolHookEpoch,
3218
+ latest_post_tool_parse_status: latestPostToolParseStatus,
3219
+ latest_post_tool_name: latestPostToolName,
3120
3220
  raw_capture_active: recentUserPrompts > 0 || recentToolEvents > 0 || recentSessionsWithRawCapture > 0
3121
3221
  };
3122
3222
  }
3223
+ function parseNullableInt(value) {
3224
+ if (!value)
3225
+ return null;
3226
+ const parsed = Number.parseInt(value, 10);
3227
+ return Number.isFinite(parsed) ? parsed : null;
3228
+ }
3123
3229
 
3124
3230
  // src/sync/auth.ts
3125
3231
  var LEGACY_PUBLIC_HOSTS = new Set(["www.candengo.com", "candengo.com"]);
@@ -3142,7 +3248,7 @@ function normalizeBaseUrl(url) {
3142
3248
  var LEGACY_CODEX_SERVER_NAME3 = `candengo-${"mem"}`;
3143
3249
  var args = process.argv.slice(2);
3144
3250
  var command = args[0];
3145
- var THIS_DIR = dirname4(fileURLToPath4(import.meta.url));
3251
+ var THIS_DIR = dirname5(fileURLToPath4(import.meta.url));
3146
3252
  var IS_BUILT_DIST = THIS_DIR.endsWith("/dist") || THIS_DIR.endsWith("\\dist");
3147
3253
  switch (command) {
3148
3254
  case "init":
@@ -3629,7 +3735,16 @@ function handleStatus() {
3629
3735
  const capture = getCaptureStatus(db, { user_id: config.user_id });
3630
3736
  console.log(` Raw capture: ${capture.raw_capture_active ? "active" : "observations-only so far"}`);
3631
3737
  console.log(` Prompts/tools: ${capture.recent_user_prompts}/${capture.recent_tool_events} in last 24h`);
3632
- console.log(` Hook state: Claude ${capture.claude_hooks_registered ? "ok" : "missing"}, Codex ${capture.codex_hooks_registered ? "ok" : "missing"}`);
3738
+ if (capture.recent_sessions_with_partial_capture > 0) {
3739
+ console.log(` Partial raw: ${capture.recent_sessions_with_partial_capture} recent session${capture.recent_sessions_with_partial_capture === 1 ? "" : "s"} missing some chronology`);
3740
+ }
3741
+ console.log(` Hook state: Claude ${capture.claude_user_prompt_hook && capture.claude_post_tool_hook ? "raw-ready" : "partial"}, Codex ${capture.codex_raw_chronology_supported ? "raw-ready" : "start/stop only"}`);
3742
+ if (capture.latest_post_tool_hook_epoch) {
3743
+ const lastSeen = new Date(capture.latest_post_tool_hook_epoch * 1000).toISOString();
3744
+ const parseStatus = capture.latest_post_tool_parse_status ?? "unknown";
3745
+ const toolName = capture.latest_post_tool_name ?? "unknown";
3746
+ console.log(` PostToolUse: ${parseStatus} (${toolName}, ${lastSeen})`);
3747
+ }
3633
3748
  try {
3634
3749
  const activeObservations = db.db.query(`SELECT * FROM observations
3635
3750
  WHERE lifecycle IN ('active', 'aging', 'pinned') AND superseded_by IS NULL`).all();
@@ -3910,9 +4025,17 @@ async function handleDoctor() {
3910
4025
  if (existsSync7(claudeSettings)) {
3911
4026
  const content = readFileSync7(claudeSettings, "utf-8");
3912
4027
  let hookCount = 0;
4028
+ let hasSessionStart = false;
4029
+ let hasUserPrompt = false;
4030
+ let hasPostToolUse = false;
4031
+ let hasStop = false;
3913
4032
  try {
3914
4033
  const settings = JSON.parse(content);
3915
4034
  const hooks = settings?.hooks ?? {};
4035
+ hasSessionStart = Array.isArray(hooks["SessionStart"]);
4036
+ hasUserPrompt = Array.isArray(hooks["UserPromptSubmit"]);
4037
+ hasPostToolUse = Array.isArray(hooks["PostToolUse"]);
4038
+ hasStop = Array.isArray(hooks["Stop"]);
3916
4039
  for (const entries of Object.values(hooks)) {
3917
4040
  if (Array.isArray(entries)) {
3918
4041
  for (const entry of entries) {
@@ -3924,8 +4047,19 @@ async function handleDoctor() {
3924
4047
  }
3925
4048
  }
3926
4049
  } catch {}
3927
- if (hookCount > 0) {
4050
+ const missingCritical = [];
4051
+ if (!hasSessionStart)
4052
+ missingCritical.push("SessionStart");
4053
+ if (!hasUserPrompt)
4054
+ missingCritical.push("UserPromptSubmit");
4055
+ if (!hasPostToolUse)
4056
+ missingCritical.push("PostToolUse");
4057
+ if (!hasStop)
4058
+ missingCritical.push("Stop");
4059
+ if (hookCount > 0 && missingCritical.length === 0) {
3928
4060
  pass(`Hooks registered (${hookCount} hook${hookCount === 1 ? "" : "s"})`);
4061
+ } else if (hookCount > 0) {
4062
+ warn(`Hooks registered but incomplete \u2014 missing ${missingCritical.join(", ")}`);
3929
4063
  } else {
3930
4064
  warn("No Engrm hooks found in Claude Code settings");
3931
4065
  }
@@ -4057,10 +4191,16 @@ async function handleDoctor() {
4057
4191
  }
4058
4192
  try {
4059
4193
  const capture = getCaptureStatus(db, { user_id: config.user_id });
4060
- if (capture.raw_capture_active) {
4194
+ if (capture.raw_capture_active && capture.recent_tool_events > 0 && capture.recent_sessions_with_partial_capture === 0) {
4061
4195
  pass(`Raw chronology active (${capture.recent_user_prompts} prompts, ${capture.recent_tool_events} tools in last 24h)`);
4196
+ } else if (capture.raw_capture_active && capture.recent_sessions_with_partial_capture > 0) {
4197
+ warn(`Raw chronology is only partially active (${capture.recent_user_prompts} prompts, ${capture.recent_tool_events} tools in last 24h; ${capture.recent_sessions_with_partial_capture} recent session${capture.recent_sessions_with_partial_capture === 1 ? "" : "s"} missing some chronology).`);
4198
+ if (capture.latest_post_tool_hook_epoch) {
4199
+ info(`Last PostToolUse hook: ${new Date(capture.latest_post_tool_hook_epoch * 1000).toISOString()} (${capture.latest_post_tool_parse_status ?? "unknown"}${capture.latest_post_tool_name ? `, ${capture.latest_post_tool_name}` : ""})`);
4200
+ }
4062
4201
  } else if (capture.claude_hooks_registered || capture.codex_hooks_registered) {
4063
- warn("Hooks are registered, but no raw prompt/tool chronology has been captured in the last 24h");
4202
+ const guidance = capture.claude_user_prompt_hook && capture.claude_post_tool_hook ? "Claude is raw-ready; open a fresh Claude Code session and perform a few actions to verify capture." : "Claude raw chronology hooks are incomplete, and Codex currently supports start/stop capture only.";
4203
+ warn(`Hooks are registered, but no raw prompt/tool chronology has been captured in the last 24h. ${guidance}`);
4064
4204
  } else {
4065
4205
  warn("Raw chronology inactive \u2014 hook registration is incomplete");
4066
4206
  }
@@ -327,7 +327,7 @@ function looksMeaningful(value) {
327
327
  // src/storage/projects.ts
328
328
  import { execSync } from "node:child_process";
329
329
  import { existsSync, readFileSync } from "node:fs";
330
- import { basename, join } from "node:path";
330
+ import { basename, dirname, join, resolve } from "node:path";
331
331
  function normaliseGitRemoteUrl(remoteUrl) {
332
332
  let url = remoteUrl.trim();
333
333
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -381,6 +381,19 @@ function getGitRemoteUrl(directory) {
381
381
  }
382
382
  }
383
383
  }
384
+ function getGitTopLevel(directory) {
385
+ try {
386
+ const root = execSync("git rev-parse --show-toplevel", {
387
+ cwd: directory,
388
+ encoding: "utf-8",
389
+ timeout: 5000,
390
+ stdio: ["pipe", "pipe", "pipe"]
391
+ }).trim();
392
+ return root || null;
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
384
397
  function readProjectConfigFile(directory) {
385
398
  const configPath = join(directory, ".engrm.json");
386
399
  if (!existsSync(configPath))
@@ -403,11 +416,12 @@ function detectProject(directory) {
403
416
  const remoteUrl = getGitRemoteUrl(directory);
404
417
  if (remoteUrl) {
405
418
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
419
+ const repoRoot = getGitTopLevel(directory) ?? directory;
406
420
  return {
407
421
  canonical_id: canonicalId,
408
422
  name: projectNameFromCanonicalId(canonicalId),
409
423
  remote_url: remoteUrl,
410
- local_path: directory
424
+ local_path: repoRoot
411
425
  };
412
426
  }
413
427
  const configFile = readProjectConfigFile(directory);
@@ -427,6 +441,32 @@ function detectProject(directory) {
427
441
  local_path: directory
428
442
  };
429
443
  }
444
+ function detectProjectForPath(filePath, fallbackCwd) {
445
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
446
+ const candidateDir = existsSync(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
447
+ const detected = detectProject(candidateDir);
448
+ if (detected.canonical_id.startsWith("local/"))
449
+ return null;
450
+ return detected;
451
+ }
452
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
453
+ const counts = new Map;
454
+ for (const rawPath of paths) {
455
+ if (!rawPath || !rawPath.trim())
456
+ continue;
457
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
458
+ if (!detected)
459
+ continue;
460
+ const existing = counts.get(detected.canonical_id);
461
+ if (existing) {
462
+ existing.count += 1;
463
+ } else {
464
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
465
+ }
466
+ }
467
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
468
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
469
+ }
430
470
 
431
471
  // src/embeddings/embedder.ts
432
472
  var _available = null;
@@ -694,7 +734,8 @@ async function saveObservation(db, config, input) {
694
734
  return { success: false, reason: "Title is required" };
695
735
  }
696
736
  const cwd = input.cwd ?? process.cwd();
697
- const detected = detectProject(cwd);
737
+ const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
738
+ const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
698
739
  const project = db.upsertProject({
699
740
  canonical_id: detected.canonical_id,
700
741
  name: detected.name,
@@ -1474,6 +1515,11 @@ import { createHash as createHash2 } from "node:crypto";
1474
1515
  var IS_BUN = typeof globalThis.Bun !== "undefined";
1475
1516
  function openDatabase(dbPath) {
1476
1517
  if (IS_BUN) {
1518
+ if (process.platform === "darwin") {
1519
+ try {
1520
+ return openNodeDatabase(dbPath);
1521
+ } catch {}
1522
+ }
1477
1523
  return openBunDatabase(dbPath);
1478
1524
  }
1479
1525
  return openNodeDatabase(dbPath);
@@ -1590,6 +1636,15 @@ class MemDatabase {
1590
1636
  }
1591
1637
  return row;
1592
1638
  }
1639
+ reassignObservationProject(observationId, projectId) {
1640
+ const existing = this.getObservationById(observationId);
1641
+ if (!existing)
1642
+ return false;
1643
+ if (existing.project_id === projectId)
1644
+ return true;
1645
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
1646
+ return true;
1647
+ }
1593
1648
  getObservationById(id) {
1594
1649
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1595
1650
  }
@@ -1723,8 +1778,13 @@ class MemDatabase {
1723
1778
  }
1724
1779
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
1725
1780
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1726
- if (existing)
1781
+ if (existing) {
1782
+ if (existing.project_id === null && projectId !== null) {
1783
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
1784
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1785
+ }
1727
1786
  return existing;
1787
+ }
1728
1788
  const now = Math.floor(Date.now() / 1000);
1729
1789
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
1730
1790
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);