engrm 0.4.7 → 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.
@@ -587,6 +587,64 @@ var MIGRATIONS = [
587
587
  );
588
588
  INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
589
589
  `
590
+ },
591
+ {
592
+ version: 9,
593
+ description: "Add first-class user prompt capture",
594
+ sql: `
595
+ CREATE TABLE IF NOT EXISTS user_prompts (
596
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
597
+ session_id TEXT NOT NULL,
598
+ project_id INTEGER REFERENCES projects(id),
599
+ prompt_number INTEGER NOT NULL,
600
+ prompt TEXT NOT NULL,
601
+ prompt_hash TEXT NOT NULL,
602
+ cwd TEXT,
603
+ user_id TEXT NOT NULL,
604
+ device_id TEXT NOT NULL,
605
+ agent TEXT DEFAULT 'claude-code',
606
+ created_at_epoch INTEGER NOT NULL,
607
+ UNIQUE(session_id, prompt_number)
608
+ );
609
+
610
+ CREATE INDEX IF NOT EXISTS idx_user_prompts_session
611
+ ON user_prompts(session_id, prompt_number DESC);
612
+ CREATE INDEX IF NOT EXISTS idx_user_prompts_project
613
+ ON user_prompts(project_id, created_at_epoch DESC);
614
+ CREATE INDEX IF NOT EXISTS idx_user_prompts_created
615
+ ON user_prompts(created_at_epoch DESC);
616
+ CREATE INDEX IF NOT EXISTS idx_user_prompts_hash
617
+ ON user_prompts(prompt_hash);
618
+ `
619
+ },
620
+ {
621
+ version: 10,
622
+ description: "Add first-class tool event chronology",
623
+ sql: `
624
+ CREATE TABLE IF NOT EXISTS tool_events (
625
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
626
+ session_id TEXT NOT NULL,
627
+ project_id INTEGER REFERENCES projects(id),
628
+ tool_name TEXT NOT NULL,
629
+ tool_input_json TEXT,
630
+ tool_response_preview TEXT,
631
+ file_path TEXT,
632
+ command TEXT,
633
+ user_id TEXT NOT NULL,
634
+ device_id TEXT NOT NULL,
635
+ agent TEXT DEFAULT 'claude-code',
636
+ created_at_epoch INTEGER NOT NULL
637
+ );
638
+
639
+ CREATE INDEX IF NOT EXISTS idx_tool_events_session
640
+ ON tool_events(session_id, created_at_epoch DESC, id DESC);
641
+ CREATE INDEX IF NOT EXISTS idx_tool_events_project
642
+ ON tool_events(project_id, created_at_epoch DESC, id DESC);
643
+ CREATE INDEX IF NOT EXISTS idx_tool_events_tool_name
644
+ ON tool_events(tool_name, created_at_epoch DESC);
645
+ CREATE INDEX IF NOT EXISTS idx_tool_events_created
646
+ ON tool_events(created_at_epoch DESC, id DESC);
647
+ `
590
648
  }
591
649
  ];
592
650
  function isVecExtensionLoaded(db) {
@@ -671,6 +729,7 @@ function ensureObservationTypes(db) {
671
729
  var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
672
730
 
673
731
  // src/storage/sqlite.ts
732
+ import { createHash as createHash2 } from "node:crypto";
674
733
  var IS_BUN = typeof globalThis.Bun !== "undefined";
675
734
  function openDatabase(dbPath) {
676
735
  if (IS_BUN) {
@@ -790,6 +849,15 @@ class MemDatabase {
790
849
  }
791
850
  return row;
792
851
  }
852
+ reassignObservationProject(observationId, projectId) {
853
+ const existing = this.getObservationById(observationId);
854
+ if (!existing)
855
+ return false;
856
+ if (existing.project_id === projectId)
857
+ return true;
858
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
859
+ return true;
860
+ }
793
861
  getObservationById(id) {
794
862
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
795
863
  }
@@ -923,8 +991,13 @@ class MemDatabase {
923
991
  }
924
992
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
925
993
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
926
- if (existing)
994
+ if (existing) {
995
+ if (existing.project_id === null && projectId !== null) {
996
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
997
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
998
+ }
927
999
  return existing;
1000
+ }
928
1001
  const now = Math.floor(Date.now() / 1000);
929
1002
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
930
1003
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
@@ -934,6 +1007,110 @@ class MemDatabase {
934
1007
  const now = Math.floor(Date.now() / 1000);
935
1008
  this.db.query("UPDATE sessions SET status = 'completed', completed_at_epoch = ? WHERE session_id = ?").run(now, sessionId);
936
1009
  }
1010
+ getSessionById(sessionId) {
1011
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) ?? null;
1012
+ }
1013
+ getRecentSessions(projectId, limit = 10, userId) {
1014
+ const visibilityClause = userId ? " AND s.user_id = ?" : "";
1015
+ if (projectId !== null) {
1016
+ return this.db.query(`SELECT
1017
+ s.*,
1018
+ p.name AS project_name,
1019
+ ss.request AS request,
1020
+ ss.completed AS completed,
1021
+ (SELECT COUNT(*) FROM user_prompts up WHERE up.session_id = s.session_id) AS prompt_count,
1022
+ (SELECT COUNT(*) FROM tool_events te WHERE te.session_id = s.session_id) AS tool_event_count
1023
+ FROM sessions s
1024
+ LEFT JOIN projects p ON p.id = s.project_id
1025
+ LEFT JOIN session_summaries ss ON ss.session_id = s.session_id
1026
+ WHERE s.project_id = ?${visibilityClause}
1027
+ ORDER BY COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) DESC, s.id DESC
1028
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1029
+ }
1030
+ return this.db.query(`SELECT
1031
+ s.*,
1032
+ p.name AS project_name,
1033
+ ss.request AS request,
1034
+ ss.completed AS completed,
1035
+ (SELECT COUNT(*) FROM user_prompts up WHERE up.session_id = s.session_id) AS prompt_count,
1036
+ (SELECT COUNT(*) FROM tool_events te WHERE te.session_id = s.session_id) AS tool_event_count
1037
+ FROM sessions s
1038
+ LEFT JOIN projects p ON p.id = s.project_id
1039
+ LEFT JOIN session_summaries ss ON ss.session_id = s.session_id
1040
+ WHERE 1 = 1${visibilityClause}
1041
+ ORDER BY COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) DESC, s.id DESC
1042
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1043
+ }
1044
+ insertUserPrompt(input) {
1045
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1046
+ const normalizedPrompt = input.prompt.trim();
1047
+ const promptHash = hashPrompt(normalizedPrompt);
1048
+ const latest = this.db.query(`SELECT * FROM user_prompts
1049
+ WHERE session_id = ?
1050
+ ORDER BY prompt_number DESC
1051
+ LIMIT 1`).get(input.session_id);
1052
+ if (latest && latest.prompt_hash === promptHash) {
1053
+ return latest;
1054
+ }
1055
+ const promptNumber = (latest?.prompt_number ?? 0) + 1;
1056
+ const result = this.db.query(`INSERT INTO user_prompts (
1057
+ session_id, project_id, prompt_number, prompt, prompt_hash, cwd,
1058
+ user_id, device_id, agent, created_at_epoch
1059
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.session_id, input.project_id, promptNumber, normalizedPrompt, promptHash, input.cwd ?? null, input.user_id, input.device_id, input.agent ?? "claude-code", createdAt);
1060
+ return this.getUserPromptById(Number(result.lastInsertRowid));
1061
+ }
1062
+ getUserPromptById(id) {
1063
+ return this.db.query("SELECT * FROM user_prompts WHERE id = ?").get(id) ?? null;
1064
+ }
1065
+ getRecentUserPrompts(projectId, limit = 10, userId) {
1066
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1067
+ if (projectId !== null) {
1068
+ return this.db.query(`SELECT * FROM user_prompts
1069
+ WHERE project_id = ?${visibilityClause}
1070
+ ORDER BY created_at_epoch DESC, prompt_number DESC
1071
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1072
+ }
1073
+ return this.db.query(`SELECT * FROM user_prompts
1074
+ WHERE 1 = 1${visibilityClause}
1075
+ ORDER BY created_at_epoch DESC, prompt_number DESC
1076
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1077
+ }
1078
+ getSessionUserPrompts(sessionId, limit = 20) {
1079
+ return this.db.query(`SELECT * FROM user_prompts
1080
+ WHERE session_id = ?
1081
+ ORDER BY prompt_number ASC
1082
+ LIMIT ?`).all(sessionId, limit);
1083
+ }
1084
+ insertToolEvent(input) {
1085
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1086
+ const result = this.db.query(`INSERT INTO tool_events (
1087
+ session_id, project_id, tool_name, tool_input_json, tool_response_preview,
1088
+ file_path, command, user_id, device_id, agent, created_at_epoch
1089
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.session_id, input.project_id, input.tool_name, input.tool_input_json ?? null, input.tool_response_preview ?? null, input.file_path ?? null, input.command ?? null, input.user_id, input.device_id, input.agent ?? "claude-code", createdAt);
1090
+ return this.getToolEventById(Number(result.lastInsertRowid));
1091
+ }
1092
+ getToolEventById(id) {
1093
+ return this.db.query("SELECT * FROM tool_events WHERE id = ?").get(id) ?? null;
1094
+ }
1095
+ getSessionToolEvents(sessionId, limit = 20) {
1096
+ return this.db.query(`SELECT * FROM tool_events
1097
+ WHERE session_id = ?
1098
+ ORDER BY created_at_epoch ASC, id ASC
1099
+ LIMIT ?`).all(sessionId, limit);
1100
+ }
1101
+ getRecentToolEvents(projectId, limit = 20, userId) {
1102
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1103
+ if (projectId !== null) {
1104
+ return this.db.query(`SELECT * FROM tool_events
1105
+ WHERE project_id = ?${visibilityClause}
1106
+ ORDER BY created_at_epoch DESC, id DESC
1107
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1108
+ }
1109
+ return this.db.query(`SELECT * FROM tool_events
1110
+ WHERE 1 = 1${visibilityClause}
1111
+ ORDER BY created_at_epoch DESC, id DESC
1112
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1113
+ }
937
1114
  addToOutbox(recordType, recordId) {
938
1115
  const now = Math.floor(Date.now() / 1000);
939
1116
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -1104,6 +1281,9 @@ class MemDatabase {
1104
1281
  this.db.query("INSERT OR REPLACE INTO packs_installed (name, installed_at, observation_count) VALUES (?, ?, ?)").run(name, now, observationCount);
1105
1282
  }
1106
1283
  }
1284
+ function hashPrompt(prompt) {
1285
+ return createHash2("sha256").update(prompt).digest("hex");
1286
+ }
1107
1287
 
1108
1288
  // src/hooks/common.ts
1109
1289
  var c = {
@@ -1162,7 +1342,7 @@ function runHook(hookName, fn) {
1162
1342
  // src/storage/projects.ts
1163
1343
  import { execSync } from "node:child_process";
1164
1344
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1165
- import { basename, join as join2 } from "node:path";
1345
+ import { basename, dirname, join as join2, resolve } from "node:path";
1166
1346
  function normaliseGitRemoteUrl(remoteUrl) {
1167
1347
  let url = remoteUrl.trim();
1168
1348
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -1216,6 +1396,19 @@ function getGitRemoteUrl(directory) {
1216
1396
  }
1217
1397
  }
1218
1398
  }
1399
+ function getGitTopLevel(directory) {
1400
+ try {
1401
+ const root = execSync("git rev-parse --show-toplevel", {
1402
+ cwd: directory,
1403
+ encoding: "utf-8",
1404
+ timeout: 5000,
1405
+ stdio: ["pipe", "pipe", "pipe"]
1406
+ }).trim();
1407
+ return root || null;
1408
+ } catch {
1409
+ return null;
1410
+ }
1411
+ }
1219
1412
  function readProjectConfigFile(directory) {
1220
1413
  const configPath = join2(directory, ".engrm.json");
1221
1414
  if (!existsSync2(configPath))
@@ -1238,11 +1431,12 @@ function detectProject(directory) {
1238
1431
  const remoteUrl = getGitRemoteUrl(directory);
1239
1432
  if (remoteUrl) {
1240
1433
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
1434
+ const repoRoot = getGitTopLevel(directory) ?? directory;
1241
1435
  return {
1242
1436
  canonical_id: canonicalId,
1243
1437
  name: projectNameFromCanonicalId(canonicalId),
1244
1438
  remote_url: remoteUrl,
1245
- local_path: directory
1439
+ local_path: repoRoot
1246
1440
  };
1247
1441
  }
1248
1442
  const configFile = readProjectConfigFile(directory);
@@ -1262,6 +1456,32 @@ function detectProject(directory) {
1262
1456
  local_path: directory
1263
1457
  };
1264
1458
  }
1459
+ function detectProjectForPath(filePath, fallbackCwd) {
1460
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
1461
+ const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
1462
+ const detected = detectProject(candidateDir);
1463
+ if (detected.canonical_id.startsWith("local/"))
1464
+ return null;
1465
+ return detected;
1466
+ }
1467
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
1468
+ const counts = new Map;
1469
+ for (const rawPath of paths) {
1470
+ if (!rawPath || !rawPath.trim())
1471
+ continue;
1472
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
1473
+ if (!detected)
1474
+ continue;
1475
+ const existing = counts.get(detected.canonical_id);
1476
+ if (existing) {
1477
+ existing.count += 1;
1478
+ } else {
1479
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
1480
+ }
1481
+ }
1482
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
1483
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
1484
+ }
1265
1485
 
1266
1486
  // hooks/sentinel.ts
1267
1487
  async function main() {