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.
- package/README.md +42 -1
- package/dist/cli.js +549 -64
- package/dist/hooks/elicitation-result.js +225 -4
- package/dist/hooks/post-tool-use.js +346 -21
- package/dist/hooks/pre-compact.js +439 -7
- package/dist/hooks/sentinel.js +223 -3
- package/dist/hooks/session-start.js +670 -43
- package/dist/hooks/stop.js +259 -7
- package/dist/hooks/user-prompt-submit.js +1441 -0
- package/dist/server.js +2378 -306
- package/package.json +1 -1
package/dist/hooks/sentinel.js
CHANGED
|
@@ -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:
|
|
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() {
|