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 +149 -14
- package/dist/hooks/elicitation-result.js +59 -4
- package/dist/hooks/post-tool-use.js +154 -33
- package/dist/hooks/pre-compact.js +188 -14
- package/dist/hooks/sentinel.js +57 -3
- package/dist/hooks/session-start.js +357 -61
- package/dist/hooks/stop.js +59 -4
- package/dist/hooks/user-prompt-submit.js +57 -3
- package/dist/server.js +878 -311
- package/package.json +1 -1
|
@@ -979,6 +979,15 @@ class MemDatabase {
|
|
|
979
979
|
}
|
|
980
980
|
return row;
|
|
981
981
|
}
|
|
982
|
+
reassignObservationProject(observationId, projectId) {
|
|
983
|
+
const existing = this.getObservationById(observationId);
|
|
984
|
+
if (!existing)
|
|
985
|
+
return false;
|
|
986
|
+
if (existing.project_id === projectId)
|
|
987
|
+
return true;
|
|
988
|
+
this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
|
|
989
|
+
return true;
|
|
990
|
+
}
|
|
982
991
|
getObservationById(id) {
|
|
983
992
|
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
984
993
|
}
|
|
@@ -1112,8 +1121,13 @@ class MemDatabase {
|
|
|
1112
1121
|
}
|
|
1113
1122
|
upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
|
|
1114
1123
|
const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
1115
|
-
if (existing)
|
|
1124
|
+
if (existing) {
|
|
1125
|
+
if (existing.project_id === null && projectId !== null) {
|
|
1126
|
+
this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
|
|
1127
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
1128
|
+
}
|
|
1116
1129
|
return existing;
|
|
1130
|
+
}
|
|
1117
1131
|
const now = Math.floor(Date.now() / 1000);
|
|
1118
1132
|
this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
|
|
1119
1133
|
VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
|
|
@@ -1780,7 +1794,7 @@ function looksMeaningful(value) {
|
|
|
1780
1794
|
// src/storage/projects.ts
|
|
1781
1795
|
import { execSync } from "node:child_process";
|
|
1782
1796
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1783
|
-
import { basename, join as join2 } from "node:path";
|
|
1797
|
+
import { basename, dirname, join as join2, resolve } from "node:path";
|
|
1784
1798
|
function normaliseGitRemoteUrl(remoteUrl) {
|
|
1785
1799
|
let url = remoteUrl.trim();
|
|
1786
1800
|
url = url.replace(/^(?:https?|ssh|git):\/\//, "");
|
|
@@ -1834,6 +1848,19 @@ function getGitRemoteUrl(directory) {
|
|
|
1834
1848
|
}
|
|
1835
1849
|
}
|
|
1836
1850
|
}
|
|
1851
|
+
function getGitTopLevel(directory) {
|
|
1852
|
+
try {
|
|
1853
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
1854
|
+
cwd: directory,
|
|
1855
|
+
encoding: "utf-8",
|
|
1856
|
+
timeout: 5000,
|
|
1857
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1858
|
+
}).trim();
|
|
1859
|
+
return root || null;
|
|
1860
|
+
} catch {
|
|
1861
|
+
return null;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1837
1864
|
function readProjectConfigFile(directory) {
|
|
1838
1865
|
const configPath = join2(directory, ".engrm.json");
|
|
1839
1866
|
if (!existsSync2(configPath))
|
|
@@ -1856,11 +1883,12 @@ function detectProject(directory) {
|
|
|
1856
1883
|
const remoteUrl = getGitRemoteUrl(directory);
|
|
1857
1884
|
if (remoteUrl) {
|
|
1858
1885
|
const canonicalId = normaliseGitRemoteUrl(remoteUrl);
|
|
1886
|
+
const repoRoot = getGitTopLevel(directory) ?? directory;
|
|
1859
1887
|
return {
|
|
1860
1888
|
canonical_id: canonicalId,
|
|
1861
1889
|
name: projectNameFromCanonicalId(canonicalId),
|
|
1862
1890
|
remote_url: remoteUrl,
|
|
1863
|
-
local_path:
|
|
1891
|
+
local_path: repoRoot
|
|
1864
1892
|
};
|
|
1865
1893
|
}
|
|
1866
1894
|
const configFile = readProjectConfigFile(directory);
|
|
@@ -1880,6 +1908,32 @@ function detectProject(directory) {
|
|
|
1880
1908
|
local_path: directory
|
|
1881
1909
|
};
|
|
1882
1910
|
}
|
|
1911
|
+
function detectProjectForPath(filePath, fallbackCwd) {
|
|
1912
|
+
const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
|
|
1913
|
+
const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
|
|
1914
|
+
const detected = detectProject(candidateDir);
|
|
1915
|
+
if (detected.canonical_id.startsWith("local/"))
|
|
1916
|
+
return null;
|
|
1917
|
+
return detected;
|
|
1918
|
+
}
|
|
1919
|
+
function detectProjectFromTouchedPaths(paths, fallbackCwd) {
|
|
1920
|
+
const counts = new Map;
|
|
1921
|
+
for (const rawPath of paths) {
|
|
1922
|
+
if (!rawPath || !rawPath.trim())
|
|
1923
|
+
continue;
|
|
1924
|
+
const detected = detectProjectForPath(rawPath, fallbackCwd);
|
|
1925
|
+
if (!detected)
|
|
1926
|
+
continue;
|
|
1927
|
+
const existing = counts.get(detected.canonical_id);
|
|
1928
|
+
if (existing) {
|
|
1929
|
+
existing.count += 1;
|
|
1930
|
+
} else {
|
|
1931
|
+
counts.set(detected.canonical_id, { project: detected, count: 1 });
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
|
|
1935
|
+
return ranked[0]?.project ?? detectProject(fallbackCwd);
|
|
1936
|
+
}
|
|
1883
1937
|
|
|
1884
1938
|
// src/embeddings/embedder.ts
|
|
1885
1939
|
var _available = null;
|
|
@@ -2147,7 +2201,8 @@ async function saveObservation(db, config, input) {
|
|
|
2147
2201
|
return { success: false, reason: "Title is required" };
|
|
2148
2202
|
}
|
|
2149
2203
|
const cwd = input.cwd ?? process.cwd();
|
|
2150
|
-
const
|
|
2204
|
+
const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
|
|
2205
|
+
const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
|
|
2151
2206
|
const project = db.upsertProject({
|
|
2152
2207
|
canonical_id: detected.canonical_id,
|
|
2153
2208
|
name: detected.name,
|
|
@@ -2599,6 +2654,7 @@ ${eventXml}`;
|
|
|
2599
2654
|
const queryOptions = {
|
|
2600
2655
|
model: options?.model ?? "haiku",
|
|
2601
2656
|
maxTurns: 1,
|
|
2657
|
+
timeout: options?.timeoutMs ?? 800,
|
|
2602
2658
|
disallowedTools: [
|
|
2603
2659
|
"Bash",
|
|
2604
2660
|
"Read",
|
|
@@ -2919,43 +2975,37 @@ function checkSessionFatigue(db, sessionId) {
|
|
|
2919
2975
|
|
|
2920
2976
|
// hooks/post-tool-use.ts
|
|
2921
2977
|
async function main() {
|
|
2922
|
-
const
|
|
2923
|
-
if (!
|
|
2978
|
+
const raw = await readStdin();
|
|
2979
|
+
if (!raw.trim())
|
|
2924
2980
|
process.exit(0);
|
|
2925
2981
|
const boot = bootstrapHook("post-tool-use");
|
|
2926
2982
|
if (!boot)
|
|
2927
2983
|
process.exit(0);
|
|
2928
2984
|
const { config, db } = boot;
|
|
2985
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2986
|
+
let event = null;
|
|
2987
|
+
try {
|
|
2988
|
+
event = JSON.parse(raw);
|
|
2989
|
+
} catch {
|
|
2990
|
+
db.setSyncState("hook_post_tool_last_seen_epoch", String(now));
|
|
2991
|
+
db.setSyncState("hook_post_tool_last_parse_status", "invalid-json");
|
|
2992
|
+
db.setSyncState("hook_post_tool_last_payload_preview", truncatePreview(raw, 400) ?? "invalid");
|
|
2993
|
+
db.close();
|
|
2994
|
+
process.exit(0);
|
|
2995
|
+
}
|
|
2996
|
+
db.setSyncState("hook_post_tool_last_seen_epoch", String(now));
|
|
2997
|
+
db.setSyncState("hook_post_tool_last_parse_status", "parsed");
|
|
2998
|
+
db.setSyncState("hook_post_tool_last_tool_name", event.tool_name ?? "(unknown)");
|
|
2999
|
+
db.setSyncState("hook_post_tool_last_payload_preview", truncatePreview(raw, 400) ?? "parsed");
|
|
2929
3000
|
try {
|
|
2930
3001
|
if (event.session_id) {
|
|
2931
|
-
|
|
2932
|
-
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
2933
|
-
db.upsertSession(event.session_id, project?.id ?? null, config.user_id, config.device_id);
|
|
2934
|
-
const metricsIncrement = {
|
|
2935
|
-
toolCalls: 1
|
|
2936
|
-
};
|
|
2937
|
-
if ((event.tool_name === "Edit" || event.tool_name === "Write") && event.tool_input["file_path"]) {
|
|
2938
|
-
metricsIncrement.files = 1;
|
|
2939
|
-
}
|
|
2940
|
-
db.incrementSessionMetrics(event.session_id, metricsIncrement);
|
|
2941
|
-
db.insertToolEvent({
|
|
2942
|
-
session_id: event.session_id,
|
|
2943
|
-
project_id: project?.id ?? null,
|
|
2944
|
-
tool_name: event.tool_name,
|
|
2945
|
-
tool_input_json: safeSerializeToolInput(event.tool_input),
|
|
2946
|
-
tool_response_preview: truncatePreview(event.tool_response, 1200),
|
|
2947
|
-
file_path: extractFilePath(event.tool_input),
|
|
2948
|
-
command: extractCommand(event.tool_input),
|
|
2949
|
-
user_id: config.user_id,
|
|
2950
|
-
device_id: config.device_id,
|
|
2951
|
-
agent: "claude-code"
|
|
2952
|
-
});
|
|
3002
|
+
persistRawToolChronology(event, config.user_id, config.device_id);
|
|
2953
3003
|
}
|
|
2954
3004
|
const textToScan = extractScanText(event);
|
|
2955
3005
|
if (textToScan) {
|
|
2956
3006
|
const findings = scanForSecrets(textToScan, config.scrubbing.custom_patterns);
|
|
2957
3007
|
if (findings.length > 0) {
|
|
2958
|
-
const detected =
|
|
3008
|
+
const detected = detectProjectForEvent(event);
|
|
2959
3009
|
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
2960
3010
|
if (project) {
|
|
2961
3011
|
for (const finding of findings) {
|
|
@@ -3019,11 +3069,12 @@ async function main() {
|
|
|
3019
3069
|
}
|
|
3020
3070
|
}
|
|
3021
3071
|
let saved = false;
|
|
3022
|
-
if (config.observer?.enabled !== false) {
|
|
3072
|
+
if (shouldRunInlineObserver(event, config.observer?.enabled !== false)) {
|
|
3023
3073
|
try {
|
|
3024
|
-
const observed = await observeToolEvent(event, {
|
|
3025
|
-
model: config.observer.model
|
|
3026
|
-
|
|
3074
|
+
const observed = await withTimeout(observeToolEvent(event, {
|
|
3075
|
+
model: config.observer.model,
|
|
3076
|
+
timeoutMs: 800
|
|
3077
|
+
}), 1000);
|
|
3027
3078
|
if (observed) {
|
|
3028
3079
|
await saveObservation(db, config, observed);
|
|
3029
3080
|
incrementObserverSaveCount(event.session_id);
|
|
@@ -3050,6 +3101,76 @@ async function main() {
|
|
|
3050
3101
|
db.close();
|
|
3051
3102
|
}
|
|
3052
3103
|
}
|
|
3104
|
+
function persistRawToolChronology(event, userId, deviceId) {
|
|
3105
|
+
const rawDb = new MemDatabase(getDbPath());
|
|
3106
|
+
try {
|
|
3107
|
+
const detected = detectProjectForEvent(event);
|
|
3108
|
+
const project = rawDb.upsertProject({
|
|
3109
|
+
canonical_id: detected.canonical_id,
|
|
3110
|
+
name: detected.name,
|
|
3111
|
+
local_path: detected.local_path,
|
|
3112
|
+
remote_url: detected.remote_url ?? null
|
|
3113
|
+
});
|
|
3114
|
+
rawDb.upsertSession(event.session_id, project.id, userId, deviceId, "claude-code");
|
|
3115
|
+
const metricsIncrement = {
|
|
3116
|
+
toolCalls: 1
|
|
3117
|
+
};
|
|
3118
|
+
if ((event.tool_name === "Edit" || event.tool_name === "Write") && event.tool_input["file_path"]) {
|
|
3119
|
+
metricsIncrement.files = 1;
|
|
3120
|
+
}
|
|
3121
|
+
rawDb.incrementSessionMetrics(event.session_id, metricsIncrement);
|
|
3122
|
+
rawDb.insertToolEvent({
|
|
3123
|
+
session_id: event.session_id,
|
|
3124
|
+
project_id: project.id,
|
|
3125
|
+
tool_name: event.tool_name,
|
|
3126
|
+
tool_input_json: safeSerializeToolInput(event.tool_input),
|
|
3127
|
+
tool_response_preview: truncatePreview(event.tool_response, 1200),
|
|
3128
|
+
file_path: extractFilePath(event.tool_input),
|
|
3129
|
+
command: extractCommand(event.tool_input),
|
|
3130
|
+
user_id: userId,
|
|
3131
|
+
device_id: deviceId,
|
|
3132
|
+
agent: "claude-code"
|
|
3133
|
+
});
|
|
3134
|
+
} finally {
|
|
3135
|
+
rawDb.close();
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
async function withTimeout(promise, timeoutMs) {
|
|
3139
|
+
let timeoutId = null;
|
|
3140
|
+
try {
|
|
3141
|
+
return await Promise.race([
|
|
3142
|
+
promise,
|
|
3143
|
+
new Promise((resolve2) => {
|
|
3144
|
+
timeoutId = setTimeout(() => resolve2(null), timeoutMs);
|
|
3145
|
+
})
|
|
3146
|
+
]);
|
|
3147
|
+
} finally {
|
|
3148
|
+
if (timeoutId)
|
|
3149
|
+
clearTimeout(timeoutId);
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
function shouldRunInlineObserver(event, observerEnabled) {
|
|
3153
|
+
if (!observerEnabled)
|
|
3154
|
+
return false;
|
|
3155
|
+
return event.tool_name === "Bash" || event.tool_name.startsWith("mcp__");
|
|
3156
|
+
}
|
|
3157
|
+
function detectProjectForEvent(event) {
|
|
3158
|
+
const touchedPaths = extractTouchedPaths(event);
|
|
3159
|
+
return touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, event.cwd) : detectProject(event.cwd);
|
|
3160
|
+
}
|
|
3161
|
+
function extractTouchedPaths(event) {
|
|
3162
|
+
const paths = [];
|
|
3163
|
+
const filePath = event.tool_input["file_path"];
|
|
3164
|
+
if (typeof filePath === "string" && filePath.trim().length > 0) {
|
|
3165
|
+
paths.push(filePath);
|
|
3166
|
+
}
|
|
3167
|
+
const extracted = extractObservation(event);
|
|
3168
|
+
if (extracted?.files_read)
|
|
3169
|
+
paths.push(...extracted.files_read);
|
|
3170
|
+
if (extracted?.files_modified)
|
|
3171
|
+
paths.push(...extracted.files_modified);
|
|
3172
|
+
return paths;
|
|
3173
|
+
}
|
|
3053
3174
|
function safeSerializeToolInput(toolInput) {
|
|
3054
3175
|
try {
|
|
3055
3176
|
const raw = JSON.stringify(toolInput);
|
|
@@ -773,6 +773,15 @@ class MemDatabase {
|
|
|
773
773
|
}
|
|
774
774
|
return row;
|
|
775
775
|
}
|
|
776
|
+
reassignObservationProject(observationId, projectId) {
|
|
777
|
+
const existing = this.getObservationById(observationId);
|
|
778
|
+
if (!existing)
|
|
779
|
+
return false;
|
|
780
|
+
if (existing.project_id === projectId)
|
|
781
|
+
return true;
|
|
782
|
+
this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
776
785
|
getObservationById(id) {
|
|
777
786
|
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
778
787
|
}
|
|
@@ -906,8 +915,13 @@ class MemDatabase {
|
|
|
906
915
|
}
|
|
907
916
|
upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
|
|
908
917
|
const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
909
|
-
if (existing)
|
|
918
|
+
if (existing) {
|
|
919
|
+
if (existing.project_id === null && projectId !== null) {
|
|
920
|
+
this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
|
|
921
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
922
|
+
}
|
|
910
923
|
return existing;
|
|
924
|
+
}
|
|
911
925
|
const now = Math.floor(Date.now() / 1000);
|
|
912
926
|
this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
|
|
913
927
|
VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
|
|
@@ -1198,7 +1212,7 @@ function hashPrompt(prompt) {
|
|
|
1198
1212
|
// src/storage/projects.ts
|
|
1199
1213
|
import { execSync } from "node:child_process";
|
|
1200
1214
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1201
|
-
import { basename, join as join2 } from "node:path";
|
|
1215
|
+
import { basename, dirname, join as join2, resolve } from "node:path";
|
|
1202
1216
|
function normaliseGitRemoteUrl(remoteUrl) {
|
|
1203
1217
|
let url = remoteUrl.trim();
|
|
1204
1218
|
url = url.replace(/^(?:https?|ssh|git):\/\//, "");
|
|
@@ -1252,6 +1266,19 @@ function getGitRemoteUrl(directory) {
|
|
|
1252
1266
|
}
|
|
1253
1267
|
}
|
|
1254
1268
|
}
|
|
1269
|
+
function getGitTopLevel(directory) {
|
|
1270
|
+
try {
|
|
1271
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
1272
|
+
cwd: directory,
|
|
1273
|
+
encoding: "utf-8",
|
|
1274
|
+
timeout: 5000,
|
|
1275
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1276
|
+
}).trim();
|
|
1277
|
+
return root || null;
|
|
1278
|
+
} catch {
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1255
1282
|
function readProjectConfigFile(directory) {
|
|
1256
1283
|
const configPath = join2(directory, ".engrm.json");
|
|
1257
1284
|
if (!existsSync2(configPath))
|
|
@@ -1274,11 +1301,12 @@ function detectProject(directory) {
|
|
|
1274
1301
|
const remoteUrl = getGitRemoteUrl(directory);
|
|
1275
1302
|
if (remoteUrl) {
|
|
1276
1303
|
const canonicalId = normaliseGitRemoteUrl(remoteUrl);
|
|
1304
|
+
const repoRoot = getGitTopLevel(directory) ?? directory;
|
|
1277
1305
|
return {
|
|
1278
1306
|
canonical_id: canonicalId,
|
|
1279
1307
|
name: projectNameFromCanonicalId(canonicalId),
|
|
1280
1308
|
remote_url: remoteUrl,
|
|
1281
|
-
local_path:
|
|
1309
|
+
local_path: repoRoot
|
|
1282
1310
|
};
|
|
1283
1311
|
}
|
|
1284
1312
|
const configFile = readProjectConfigFile(directory);
|
|
@@ -1298,6 +1326,32 @@ function detectProject(directory) {
|
|
|
1298
1326
|
local_path: directory
|
|
1299
1327
|
};
|
|
1300
1328
|
}
|
|
1329
|
+
function detectProjectForPath(filePath, fallbackCwd) {
|
|
1330
|
+
const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
|
|
1331
|
+
const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
|
|
1332
|
+
const detected = detectProject(candidateDir);
|
|
1333
|
+
if (detected.canonical_id.startsWith("local/"))
|
|
1334
|
+
return null;
|
|
1335
|
+
return detected;
|
|
1336
|
+
}
|
|
1337
|
+
function detectProjectFromTouchedPaths(paths, fallbackCwd) {
|
|
1338
|
+
const counts = new Map;
|
|
1339
|
+
for (const rawPath of paths) {
|
|
1340
|
+
if (!rawPath || !rawPath.trim())
|
|
1341
|
+
continue;
|
|
1342
|
+
const detected = detectProjectForPath(rawPath, fallbackCwd);
|
|
1343
|
+
if (!detected)
|
|
1344
|
+
continue;
|
|
1345
|
+
const existing = counts.get(detected.canonical_id);
|
|
1346
|
+
if (existing) {
|
|
1347
|
+
existing.count += 1;
|
|
1348
|
+
} else {
|
|
1349
|
+
counts.set(detected.canonical_id, { project: detected, count: 1 });
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
|
|
1353
|
+
return ranked[0]?.project ?? detectProject(fallbackCwd);
|
|
1354
|
+
}
|
|
1301
1355
|
|
|
1302
1356
|
// src/capture/dedup.ts
|
|
1303
1357
|
function tokenise(text) {
|
|
@@ -1543,6 +1597,32 @@ function computeObservationPriority(obs, nowEpoch) {
|
|
|
1543
1597
|
}
|
|
1544
1598
|
|
|
1545
1599
|
// src/context/inject.ts
|
|
1600
|
+
function tokenizeProjectHint(text) {
|
|
1601
|
+
return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
|
|
1602
|
+
}
|
|
1603
|
+
function isObservationRelatedToProject(obs, detected) {
|
|
1604
|
+
const hints = new Set([
|
|
1605
|
+
...tokenizeProjectHint(detected.name),
|
|
1606
|
+
...tokenizeProjectHint(detected.canonical_id)
|
|
1607
|
+
]);
|
|
1608
|
+
if (hints.size === 0)
|
|
1609
|
+
return false;
|
|
1610
|
+
const haystack = [
|
|
1611
|
+
obs.title,
|
|
1612
|
+
obs.narrative ?? "",
|
|
1613
|
+
obs.facts ?? "",
|
|
1614
|
+
obs.concepts ?? "",
|
|
1615
|
+
obs.files_read ?? "",
|
|
1616
|
+
obs.files_modified ?? "",
|
|
1617
|
+
obs._source_project ?? ""
|
|
1618
|
+
].join(`
|
|
1619
|
+
`).toLowerCase();
|
|
1620
|
+
for (const hint of hints) {
|
|
1621
|
+
if (haystack.includes(hint))
|
|
1622
|
+
return true;
|
|
1623
|
+
}
|
|
1624
|
+
return false;
|
|
1625
|
+
}
|
|
1546
1626
|
function estimateTokens(text) {
|
|
1547
1627
|
if (!text)
|
|
1548
1628
|
return 0;
|
|
@@ -1618,6 +1698,9 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1618
1698
|
}
|
|
1619
1699
|
return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
|
|
1620
1700
|
});
|
|
1701
|
+
if (isNewProject) {
|
|
1702
|
+
crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
|
|
1703
|
+
}
|
|
1621
1704
|
}
|
|
1622
1705
|
const seenIds = new Set(pinned.map((o) => o.id));
|
|
1623
1706
|
const dedupedRecent = recent.filter((o) => {
|
|
@@ -1648,6 +1731,7 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1648
1731
|
const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
|
|
1649
1732
|
const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
|
|
1650
1733
|
const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
|
|
1734
|
+
const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
|
|
1651
1735
|
return {
|
|
1652
1736
|
project_name: projectName,
|
|
1653
1737
|
canonical_id: canonicalId,
|
|
@@ -1657,7 +1741,8 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1657
1741
|
recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
|
|
1658
1742
|
recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
|
|
1659
1743
|
recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
|
|
1660
|
-
projectTypeCounts: projectTypeCounts2
|
|
1744
|
+
projectTypeCounts: projectTypeCounts2,
|
|
1745
|
+
recentOutcomes: recentOutcomes2
|
|
1661
1746
|
};
|
|
1662
1747
|
}
|
|
1663
1748
|
let remainingBudget = tokenBudget - 30;
|
|
@@ -1684,6 +1769,7 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1684
1769
|
const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
|
|
1685
1770
|
const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
|
|
1686
1771
|
const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
|
|
1772
|
+
const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
|
|
1687
1773
|
let securityFindings = [];
|
|
1688
1774
|
if (!isNewProject) {
|
|
1689
1775
|
try {
|
|
@@ -1741,7 +1827,8 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1741
1827
|
recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
|
|
1742
1828
|
recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
|
|
1743
1829
|
recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
|
|
1744
|
-
projectTypeCounts
|
|
1830
|
+
projectTypeCounts,
|
|
1831
|
+
recentOutcomes
|
|
1745
1832
|
};
|
|
1746
1833
|
}
|
|
1747
1834
|
function estimateObservationTokens(obs, index) {
|
|
@@ -1778,12 +1865,15 @@ function formatContextForInjection(context) {
|
|
|
1778
1865
|
lines.push("");
|
|
1779
1866
|
}
|
|
1780
1867
|
if (context.recentPrompts && context.recentPrompts.length > 0) {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1868
|
+
const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
|
|
1869
|
+
if (promptLines.length > 0) {
|
|
1870
|
+
lines.push("## Recent Requests");
|
|
1871
|
+
for (const prompt of promptLines) {
|
|
1872
|
+
const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
|
|
1873
|
+
lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
|
|
1874
|
+
}
|
|
1875
|
+
lines.push("");
|
|
1785
1876
|
}
|
|
1786
|
-
lines.push("");
|
|
1787
1877
|
}
|
|
1788
1878
|
if (context.recentToolEvents && context.recentToolEvents.length > 0) {
|
|
1789
1879
|
lines.push("## Recent Tools");
|
|
@@ -1793,10 +1883,22 @@ function formatContextForInjection(context) {
|
|
|
1793
1883
|
lines.push("");
|
|
1794
1884
|
}
|
|
1795
1885
|
if (context.recentSessions && context.recentSessions.length > 0) {
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1886
|
+
const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
|
|
1887
|
+
const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
|
|
1888
|
+
if (summary === "(no summary)")
|
|
1889
|
+
return null;
|
|
1890
|
+
return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
|
|
1891
|
+
}).filter((line) => Boolean(line));
|
|
1892
|
+
if (recentSessionLines.length > 0) {
|
|
1893
|
+
lines.push("## Recent Sessions");
|
|
1894
|
+
lines.push(...recentSessionLines);
|
|
1895
|
+
lines.push("");
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
if (context.recentOutcomes && context.recentOutcomes.length > 0) {
|
|
1899
|
+
lines.push("## Recent Outcomes");
|
|
1900
|
+
for (const outcome of context.recentOutcomes.slice(0, 5)) {
|
|
1901
|
+
lines.push(`- ${truncateText(outcome, 160)}`);
|
|
1800
1902
|
}
|
|
1801
1903
|
lines.push("");
|
|
1802
1904
|
}
|
|
@@ -1876,6 +1978,14 @@ function formatSessionBrief(summary) {
|
|
|
1876
1978
|
}
|
|
1877
1979
|
return lines;
|
|
1878
1980
|
}
|
|
1981
|
+
function chooseMeaningfulSessionHeadline(request, completed) {
|
|
1982
|
+
if (request && !looksLikeFileOperationTitle(request))
|
|
1983
|
+
return request;
|
|
1984
|
+
const completedItems = extractMeaningfulLines(completed, 1);
|
|
1985
|
+
if (completedItems.length > 0)
|
|
1986
|
+
return completedItems[0];
|
|
1987
|
+
return request ?? completed ?? "(no summary)";
|
|
1988
|
+
}
|
|
1879
1989
|
function formatSummarySection(value, maxLen) {
|
|
1880
1990
|
if (!value)
|
|
1881
1991
|
return null;
|
|
@@ -1900,6 +2010,26 @@ function truncateText(text, maxLen) {
|
|
|
1900
2010
|
return text;
|
|
1901
2011
|
return text.slice(0, maxLen - 3) + "...";
|
|
1902
2012
|
}
|
|
2013
|
+
function isMeaningfulPrompt(value) {
|
|
2014
|
+
if (!value)
|
|
2015
|
+
return false;
|
|
2016
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
2017
|
+
if (compact.length < 8)
|
|
2018
|
+
return false;
|
|
2019
|
+
return /[a-z]{3,}/i.test(compact);
|
|
2020
|
+
}
|
|
2021
|
+
function looksLikeFileOperationTitle(value) {
|
|
2022
|
+
return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
|
|
2023
|
+
}
|
|
2024
|
+
function stripInlineSectionLabel(value) {
|
|
2025
|
+
return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
|
|
2026
|
+
}
|
|
2027
|
+
function extractMeaningfulLines(value, limit) {
|
|
2028
|
+
if (!value)
|
|
2029
|
+
return [];
|
|
2030
|
+
return value.split(`
|
|
2031
|
+
`).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle(line)).slice(0, limit);
|
|
2032
|
+
}
|
|
1903
2033
|
function formatObservationDetailFromContext(obs) {
|
|
1904
2034
|
if (obs.facts) {
|
|
1905
2035
|
const bullets = parseFacts(obs.facts);
|
|
@@ -1999,6 +2129,50 @@ function getProjectTypeCounts(db, projectId, userId) {
|
|
|
1999
2129
|
}
|
|
2000
2130
|
return counts;
|
|
2001
2131
|
}
|
|
2132
|
+
function getRecentOutcomes(db, projectId, userId) {
|
|
2133
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
2134
|
+
const visibilityParams = userId ? [userId] : [];
|
|
2135
|
+
const summaries = db.db.query(`SELECT * FROM session_summaries
|
|
2136
|
+
WHERE project_id = ?
|
|
2137
|
+
ORDER BY created_at_epoch DESC
|
|
2138
|
+
LIMIT 6`).all(projectId);
|
|
2139
|
+
const picked = [];
|
|
2140
|
+
const seen = new Set;
|
|
2141
|
+
for (const summary of summaries) {
|
|
2142
|
+
for (const line of [
|
|
2143
|
+
...extractMeaningfulLines(summary.completed, 2),
|
|
2144
|
+
...extractMeaningfulLines(summary.learned, 1)
|
|
2145
|
+
]) {
|
|
2146
|
+
const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2147
|
+
if (!normalized || seen.has(normalized))
|
|
2148
|
+
continue;
|
|
2149
|
+
seen.add(normalized);
|
|
2150
|
+
picked.push(line);
|
|
2151
|
+
if (picked.length >= 5)
|
|
2152
|
+
return picked;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
const rows = db.db.query(`SELECT * FROM observations
|
|
2156
|
+
WHERE project_id = ?
|
|
2157
|
+
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
2158
|
+
AND superseded_by IS NULL
|
|
2159
|
+
${visibilityClause}
|
|
2160
|
+
ORDER BY created_at_epoch DESC
|
|
2161
|
+
LIMIT 20`).all(projectId, ...visibilityParams);
|
|
2162
|
+
for (const obs of rows) {
|
|
2163
|
+
if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
|
|
2164
|
+
continue;
|
|
2165
|
+
const title = stripInlineSectionLabel(obs.title);
|
|
2166
|
+
const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2167
|
+
if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle(title))
|
|
2168
|
+
continue;
|
|
2169
|
+
seen.add(normalized);
|
|
2170
|
+
picked.push(title);
|
|
2171
|
+
if (picked.length >= 5)
|
|
2172
|
+
break;
|
|
2173
|
+
}
|
|
2174
|
+
return picked;
|
|
2175
|
+
}
|
|
2002
2176
|
|
|
2003
2177
|
// hooks/pre-compact.ts
|
|
2004
2178
|
function formatCurrentSessionContext(observations) {
|