engrm 0.4.8 → 0.4.9

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
 
@@ -805,6 +805,15 @@ class MemDatabase {
805
805
  }
806
806
  return row;
807
807
  }
808
+ reassignObservationProject(observationId, projectId) {
809
+ const existing = this.getObservationById(observationId);
810
+ if (!existing)
811
+ return false;
812
+ if (existing.project_id === projectId)
813
+ return true;
814
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
815
+ return true;
816
+ }
808
817
  getObservationById(id) {
809
818
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
810
819
  }
@@ -938,8 +947,13 @@ class MemDatabase {
938
947
  }
939
948
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
940
949
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
941
- if (existing)
950
+ if (existing) {
951
+ if (existing.project_id === null && projectId !== null) {
952
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
953
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
954
+ }
942
955
  return existing;
956
+ }
943
957
  const now = Math.floor(Date.now() / 1000);
944
958
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
945
959
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
@@ -2108,7 +2122,7 @@ function registerAll() {
2108
2122
 
2109
2123
  // src/packs/loader.ts
2110
2124
  import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync } from "node:fs";
2111
- import { join as join4, dirname as dirname2 } from "node:path";
2125
+ import { join as join4, dirname as dirname3 } from "node:path";
2112
2126
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2113
2127
 
2114
2128
  // src/tools/save.ts
@@ -2436,7 +2450,7 @@ function looksMeaningful(value) {
2436
2450
  // src/storage/projects.ts
2437
2451
  import { execSync } from "node:child_process";
2438
2452
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
2439
- import { basename, join as join3 } from "node:path";
2453
+ import { basename, dirname as dirname2, join as join3, resolve } from "node:path";
2440
2454
  function normaliseGitRemoteUrl(remoteUrl) {
2441
2455
  let url = remoteUrl.trim();
2442
2456
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -2490,6 +2504,19 @@ function getGitRemoteUrl(directory) {
2490
2504
  }
2491
2505
  }
2492
2506
  }
2507
+ function getGitTopLevel(directory) {
2508
+ try {
2509
+ const root = execSync("git rev-parse --show-toplevel", {
2510
+ cwd: directory,
2511
+ encoding: "utf-8",
2512
+ timeout: 5000,
2513
+ stdio: ["pipe", "pipe", "pipe"]
2514
+ }).trim();
2515
+ return root || null;
2516
+ } catch {
2517
+ return null;
2518
+ }
2519
+ }
2493
2520
  function readProjectConfigFile(directory) {
2494
2521
  const configPath = join3(directory, ".engrm.json");
2495
2522
  if (!existsSync3(configPath))
@@ -2512,11 +2539,12 @@ function detectProject(directory) {
2512
2539
  const remoteUrl = getGitRemoteUrl(directory);
2513
2540
  if (remoteUrl) {
2514
2541
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
2542
+ const repoRoot = getGitTopLevel(directory) ?? directory;
2515
2543
  return {
2516
2544
  canonical_id: canonicalId,
2517
2545
  name: projectNameFromCanonicalId(canonicalId),
2518
2546
  remote_url: remoteUrl,
2519
- local_path: directory
2547
+ local_path: repoRoot
2520
2548
  };
2521
2549
  }
2522
2550
  const configFile = readProjectConfigFile(directory);
@@ -2536,6 +2564,32 @@ function detectProject(directory) {
2536
2564
  local_path: directory
2537
2565
  };
2538
2566
  }
2567
+ function detectProjectForPath(filePath, fallbackCwd) {
2568
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
2569
+ const candidateDir = existsSync3(absolutePath) && !absolutePath.endsWith("/") ? dirname2(absolutePath) : dirname2(absolutePath);
2570
+ const detected = detectProject(candidateDir);
2571
+ if (detected.canonical_id.startsWith("local/"))
2572
+ return null;
2573
+ return detected;
2574
+ }
2575
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
2576
+ const counts = new Map;
2577
+ for (const rawPath of paths) {
2578
+ if (!rawPath || !rawPath.trim())
2579
+ continue;
2580
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
2581
+ if (!detected)
2582
+ continue;
2583
+ const existing = counts.get(detected.canonical_id);
2584
+ if (existing) {
2585
+ existing.count += 1;
2586
+ } else {
2587
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
2588
+ }
2589
+ }
2590
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
2591
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
2592
+ }
2539
2593
 
2540
2594
  // src/embeddings/embedder.ts
2541
2595
  var _available = null;
@@ -2814,7 +2868,8 @@ async function saveObservation(db, config, input) {
2814
2868
  return { success: false, reason: "Title is required" };
2815
2869
  }
2816
2870
  const cwd = input.cwd ?? process.cwd();
2817
- const detected = detectProject(cwd);
2871
+ const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
2872
+ const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
2818
2873
  const project = db.upsertProject({
2819
2874
  canonical_id: detected.canonical_id,
2820
2875
  name: detected.name,
@@ -2941,7 +2996,7 @@ function toRelativePath(filePath, projectRoot) {
2941
2996
 
2942
2997
  // src/packs/loader.ts
2943
2998
  function getPacksDir() {
2944
- const thisDir = dirname2(fileURLToPath2(import.meta.url));
2999
+ const thisDir = dirname3(fileURLToPath2(import.meta.url));
2945
3000
  return join4(thisDir, "..", "..", "packs");
2946
3001
  }
2947
3002
  function listPacks() {
@@ -2991,10 +3046,10 @@ async function installPack(db, config, packName, cwd) {
2991
3046
 
2992
3047
  // src/sentinel/rules.ts
2993
3048
  import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "node:fs";
2994
- import { join as join5, dirname as dirname3 } from "node:path";
3049
+ import { join as join5, dirname as dirname4 } from "node:path";
2995
3050
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2996
3051
  function getRulePacksDir() {
2997
- const thisDir = dirname3(fileURLToPath3(import.meta.url));
3052
+ const thisDir = dirname4(fileURLToPath3(import.meta.url));
2998
3053
  return join5(thisDir, "rule-packs");
2999
3054
  }
3000
3055
  function listRulePacks() {
@@ -3065,10 +3120,18 @@ function getCaptureStatus(db, input = {}) {
3065
3120
  const codexMcpRegistered = codexConfigContent.includes("[mcp_servers.engrm]") || codexConfigContent.includes(`[mcp_servers.${LEGACY_CODEX_SERVER_NAME2}]`);
3066
3121
  const codexHooksRegistered = codexHooksContent.includes('"SessionStart"') && codexHooksContent.includes('"Stop"');
3067
3122
  let claudeHookCount = 0;
3123
+ let claudeSessionStartHook = false;
3124
+ let claudeUserPromptHook = false;
3125
+ let claudePostToolHook = false;
3126
+ let claudeStopHook = false;
3068
3127
  if (claudeHooksRegistered) {
3069
3128
  try {
3070
3129
  const settings = JSON.parse(claudeSettingsContent);
3071
3130
  const hooks = settings?.hooks ?? {};
3131
+ claudeSessionStartHook = Array.isArray(hooks["SessionStart"]);
3132
+ claudeUserPromptHook = Array.isArray(hooks["UserPromptSubmit"]);
3133
+ claudePostToolHook = Array.isArray(hooks["PostToolUse"]);
3134
+ claudeStopHook = Array.isArray(hooks["Stop"]);
3072
3135
  for (const entries of Object.values(hooks)) {
3073
3136
  if (!Array.isArray(entries))
3074
3137
  continue;
@@ -3081,6 +3144,13 @@ function getCaptureStatus(db, input = {}) {
3081
3144
  }
3082
3145
  } catch {}
3083
3146
  }
3147
+ let codexSessionStartHook = false;
3148
+ let codexStopHook = false;
3149
+ try {
3150
+ const hooks = codexHooksContent ? JSON.parse(codexHooksContent)?.hooks ?? {} : {};
3151
+ codexSessionStartHook = Array.isArray(hooks["SessionStart"]);
3152
+ codexStopHook = Array.isArray(hooks["Stop"]);
3153
+ } catch {}
3084
3154
  const visibilityClause = input.user_id ? " AND user_id = ?" : "";
3085
3155
  const params = input.user_id ? [sinceEpoch, input.user_id] : [sinceEpoch];
3086
3156
  const recentUserPrompts = db.db.query(`SELECT COUNT(*) as count FROM user_prompts
@@ -3095,6 +3165,17 @@ function getCaptureStatus(db, input = {}) {
3095
3165
  EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
3096
3166
  OR EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
3097
3167
  )`).get(...params)?.count ?? 0;
3168
+ const recentSessionsWithPartialCapture = db.db.query(`SELECT COUNT(*) as count
3169
+ FROM sessions s
3170
+ WHERE COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) >= ?
3171
+ ${input.user_id ? "AND s.user_id = ?" : ""}
3172
+ AND (
3173
+ (s.tool_calls_count > 0 AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id))
3174
+ OR (
3175
+ EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
3176
+ AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
3177
+ )
3178
+ )`).get(...params)?.count ?? 0;
3098
3179
  const latestPromptEpoch = db.db.query(`SELECT created_at_epoch FROM user_prompts
3099
3180
  WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
3100
3181
  ORDER BY created_at_epoch DESC, prompt_number DESC
@@ -3103,6 +3184,9 @@ function getCaptureStatus(db, input = {}) {
3103
3184
  WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
3104
3185
  ORDER BY created_at_epoch DESC, id DESC
3105
3186
  LIMIT 1`).get(...input.user_id ? [input.user_id] : [])?.created_at_epoch ?? null;
3187
+ const latestPostToolHookEpoch = parseNullableInt(db.getSyncState("hook_post_tool_last_seen_epoch"));
3188
+ const latestPostToolParseStatus = db.getSyncState("hook_post_tool_last_parse_status");
3189
+ const latestPostToolName = db.getSyncState("hook_post_tool_last_tool_name");
3106
3190
  const schemaVersion = getSchemaVersion(db.db);
3107
3191
  return {
3108
3192
  schema_version: schemaVersion,
@@ -3110,16 +3194,33 @@ function getCaptureStatus(db, input = {}) {
3110
3194
  claude_mcp_registered: claudeMcpRegistered,
3111
3195
  claude_hooks_registered: claudeHooksRegistered,
3112
3196
  claude_hook_count: claudeHookCount,
3197
+ claude_session_start_hook: claudeSessionStartHook,
3198
+ claude_user_prompt_hook: claudeUserPromptHook,
3199
+ claude_post_tool_hook: claudePostToolHook,
3200
+ claude_stop_hook: claudeStopHook,
3113
3201
  codex_mcp_registered: codexMcpRegistered,
3114
3202
  codex_hooks_registered: codexHooksRegistered,
3203
+ codex_session_start_hook: codexSessionStartHook,
3204
+ codex_stop_hook: codexStopHook,
3205
+ codex_raw_chronology_supported: false,
3115
3206
  recent_user_prompts: recentUserPrompts,
3116
3207
  recent_tool_events: recentToolEvents,
3117
3208
  recent_sessions_with_raw_capture: recentSessionsWithRawCapture,
3209
+ recent_sessions_with_partial_capture: recentSessionsWithPartialCapture,
3118
3210
  latest_prompt_epoch: latestPromptEpoch,
3119
3211
  latest_tool_event_epoch: latestToolEventEpoch,
3212
+ latest_post_tool_hook_epoch: latestPostToolHookEpoch,
3213
+ latest_post_tool_parse_status: latestPostToolParseStatus,
3214
+ latest_post_tool_name: latestPostToolName,
3120
3215
  raw_capture_active: recentUserPrompts > 0 || recentToolEvents > 0 || recentSessionsWithRawCapture > 0
3121
3216
  };
3122
3217
  }
3218
+ function parseNullableInt(value) {
3219
+ if (!value)
3220
+ return null;
3221
+ const parsed = Number.parseInt(value, 10);
3222
+ return Number.isFinite(parsed) ? parsed : null;
3223
+ }
3123
3224
 
3124
3225
  // src/sync/auth.ts
3125
3226
  var LEGACY_PUBLIC_HOSTS = new Set(["www.candengo.com", "candengo.com"]);
@@ -3142,7 +3243,7 @@ function normalizeBaseUrl(url) {
3142
3243
  var LEGACY_CODEX_SERVER_NAME3 = `candengo-${"mem"}`;
3143
3244
  var args = process.argv.slice(2);
3144
3245
  var command = args[0];
3145
- var THIS_DIR = dirname4(fileURLToPath4(import.meta.url));
3246
+ var THIS_DIR = dirname5(fileURLToPath4(import.meta.url));
3146
3247
  var IS_BUILT_DIST = THIS_DIR.endsWith("/dist") || THIS_DIR.endsWith("\\dist");
3147
3248
  switch (command) {
3148
3249
  case "init":
@@ -3629,7 +3730,16 @@ function handleStatus() {
3629
3730
  const capture = getCaptureStatus(db, { user_id: config.user_id });
3630
3731
  console.log(` Raw capture: ${capture.raw_capture_active ? "active" : "observations-only so far"}`);
3631
3732
  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"}`);
3733
+ if (capture.recent_sessions_with_partial_capture > 0) {
3734
+ console.log(` Partial raw: ${capture.recent_sessions_with_partial_capture} recent session${capture.recent_sessions_with_partial_capture === 1 ? "" : "s"} missing some chronology`);
3735
+ }
3736
+ 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"}`);
3737
+ if (capture.latest_post_tool_hook_epoch) {
3738
+ const lastSeen = new Date(capture.latest_post_tool_hook_epoch * 1000).toISOString();
3739
+ const parseStatus = capture.latest_post_tool_parse_status ?? "unknown";
3740
+ const toolName = capture.latest_post_tool_name ?? "unknown";
3741
+ console.log(` PostToolUse: ${parseStatus} (${toolName}, ${lastSeen})`);
3742
+ }
3633
3743
  try {
3634
3744
  const activeObservations = db.db.query(`SELECT * FROM observations
3635
3745
  WHERE lifecycle IN ('active', 'aging', 'pinned') AND superseded_by IS NULL`).all();
@@ -3910,9 +4020,17 @@ async function handleDoctor() {
3910
4020
  if (existsSync7(claudeSettings)) {
3911
4021
  const content = readFileSync7(claudeSettings, "utf-8");
3912
4022
  let hookCount = 0;
4023
+ let hasSessionStart = false;
4024
+ let hasUserPrompt = false;
4025
+ let hasPostToolUse = false;
4026
+ let hasStop = false;
3913
4027
  try {
3914
4028
  const settings = JSON.parse(content);
3915
4029
  const hooks = settings?.hooks ?? {};
4030
+ hasSessionStart = Array.isArray(hooks["SessionStart"]);
4031
+ hasUserPrompt = Array.isArray(hooks["UserPromptSubmit"]);
4032
+ hasPostToolUse = Array.isArray(hooks["PostToolUse"]);
4033
+ hasStop = Array.isArray(hooks["Stop"]);
3916
4034
  for (const entries of Object.values(hooks)) {
3917
4035
  if (Array.isArray(entries)) {
3918
4036
  for (const entry of entries) {
@@ -3924,8 +4042,19 @@ async function handleDoctor() {
3924
4042
  }
3925
4043
  }
3926
4044
  } catch {}
3927
- if (hookCount > 0) {
4045
+ const missingCritical = [];
4046
+ if (!hasSessionStart)
4047
+ missingCritical.push("SessionStart");
4048
+ if (!hasUserPrompt)
4049
+ missingCritical.push("UserPromptSubmit");
4050
+ if (!hasPostToolUse)
4051
+ missingCritical.push("PostToolUse");
4052
+ if (!hasStop)
4053
+ missingCritical.push("Stop");
4054
+ if (hookCount > 0 && missingCritical.length === 0) {
3928
4055
  pass(`Hooks registered (${hookCount} hook${hookCount === 1 ? "" : "s"})`);
4056
+ } else if (hookCount > 0) {
4057
+ warn(`Hooks registered but incomplete \u2014 missing ${missingCritical.join(", ")}`);
3929
4058
  } else {
3930
4059
  warn("No Engrm hooks found in Claude Code settings");
3931
4060
  }
@@ -4057,10 +4186,16 @@ async function handleDoctor() {
4057
4186
  }
4058
4187
  try {
4059
4188
  const capture = getCaptureStatus(db, { user_id: config.user_id });
4060
- if (capture.raw_capture_active) {
4189
+ if (capture.raw_capture_active && capture.recent_tool_events > 0 && capture.recent_sessions_with_partial_capture === 0) {
4061
4190
  pass(`Raw chronology active (${capture.recent_user_prompts} prompts, ${capture.recent_tool_events} tools in last 24h)`);
4191
+ } else if (capture.raw_capture_active && capture.recent_sessions_with_partial_capture > 0) {
4192
+ 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).`);
4193
+ if (capture.latest_post_tool_hook_epoch) {
4194
+ 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}` : ""})`);
4195
+ }
4062
4196
  } 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");
4197
+ 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.";
4198
+ warn(`Hooks are registered, but no raw prompt/tool chronology has been captured in the last 24h. ${guidance}`);
4064
4199
  } else {
4065
4200
  warn("Raw chronology inactive \u2014 hook registration is incomplete");
4066
4201
  }
@@ -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,
@@ -1590,6 +1631,15 @@ class MemDatabase {
1590
1631
  }
1591
1632
  return row;
1592
1633
  }
1634
+ reassignObservationProject(observationId, projectId) {
1635
+ const existing = this.getObservationById(observationId);
1636
+ if (!existing)
1637
+ return false;
1638
+ if (existing.project_id === projectId)
1639
+ return true;
1640
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
1641
+ return true;
1642
+ }
1593
1643
  getObservationById(id) {
1594
1644
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1595
1645
  }
@@ -1723,8 +1773,13 @@ class MemDatabase {
1723
1773
  }
1724
1774
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
1725
1775
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1726
- if (existing)
1776
+ if (existing) {
1777
+ if (existing.project_id === null && projectId !== null) {
1778
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
1779
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1780
+ }
1727
1781
  return existing;
1782
+ }
1728
1783
  const now = Math.floor(Date.now() / 1000);
1729
1784
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
1730
1785
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);