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 +154 -14
- package/dist/hooks/elicitation-result.js +64 -4
- package/dist/hooks/post-tool-use.js +159 -33
- package/dist/hooks/pre-compact.js +193 -14
- package/dist/hooks/sentinel.js +62 -3
- package/dist/hooks/session-start.js +362 -61
- package/dist/hooks/stop.js +64 -4
- package/dist/hooks/user-prompt-submit.js +62 -3
- package/dist/server.js +883 -311
- package/package.json +1 -1
|
@@ -863,6 +863,11 @@ import { createHash as createHash2 } from "node:crypto";
|
|
|
863
863
|
var IS_BUN = typeof globalThis.Bun !== "undefined";
|
|
864
864
|
function openDatabase(dbPath) {
|
|
865
865
|
if (IS_BUN) {
|
|
866
|
+
if (process.platform === "darwin") {
|
|
867
|
+
try {
|
|
868
|
+
return openNodeDatabase(dbPath);
|
|
869
|
+
} catch {}
|
|
870
|
+
}
|
|
866
871
|
return openBunDatabase(dbPath);
|
|
867
872
|
}
|
|
868
873
|
return openNodeDatabase(dbPath);
|
|
@@ -979,6 +984,15 @@ class MemDatabase {
|
|
|
979
984
|
}
|
|
980
985
|
return row;
|
|
981
986
|
}
|
|
987
|
+
reassignObservationProject(observationId, projectId) {
|
|
988
|
+
const existing = this.getObservationById(observationId);
|
|
989
|
+
if (!existing)
|
|
990
|
+
return false;
|
|
991
|
+
if (existing.project_id === projectId)
|
|
992
|
+
return true;
|
|
993
|
+
this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
982
996
|
getObservationById(id) {
|
|
983
997
|
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
984
998
|
}
|
|
@@ -1112,8 +1126,13 @@ class MemDatabase {
|
|
|
1112
1126
|
}
|
|
1113
1127
|
upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
|
|
1114
1128
|
const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
1115
|
-
if (existing)
|
|
1129
|
+
if (existing) {
|
|
1130
|
+
if (existing.project_id === null && projectId !== null) {
|
|
1131
|
+
this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
|
|
1132
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
1133
|
+
}
|
|
1116
1134
|
return existing;
|
|
1135
|
+
}
|
|
1117
1136
|
const now = Math.floor(Date.now() / 1000);
|
|
1118
1137
|
this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
|
|
1119
1138
|
VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
|
|
@@ -1780,7 +1799,7 @@ function looksMeaningful(value) {
|
|
|
1780
1799
|
// src/storage/projects.ts
|
|
1781
1800
|
import { execSync } from "node:child_process";
|
|
1782
1801
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1783
|
-
import { basename, join as join2 } from "node:path";
|
|
1802
|
+
import { basename, dirname, join as join2, resolve } from "node:path";
|
|
1784
1803
|
function normaliseGitRemoteUrl(remoteUrl) {
|
|
1785
1804
|
let url = remoteUrl.trim();
|
|
1786
1805
|
url = url.replace(/^(?:https?|ssh|git):\/\//, "");
|
|
@@ -1834,6 +1853,19 @@ function getGitRemoteUrl(directory) {
|
|
|
1834
1853
|
}
|
|
1835
1854
|
}
|
|
1836
1855
|
}
|
|
1856
|
+
function getGitTopLevel(directory) {
|
|
1857
|
+
try {
|
|
1858
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
1859
|
+
cwd: directory,
|
|
1860
|
+
encoding: "utf-8",
|
|
1861
|
+
timeout: 5000,
|
|
1862
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1863
|
+
}).trim();
|
|
1864
|
+
return root || null;
|
|
1865
|
+
} catch {
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1837
1869
|
function readProjectConfigFile(directory) {
|
|
1838
1870
|
const configPath = join2(directory, ".engrm.json");
|
|
1839
1871
|
if (!existsSync2(configPath))
|
|
@@ -1856,11 +1888,12 @@ function detectProject(directory) {
|
|
|
1856
1888
|
const remoteUrl = getGitRemoteUrl(directory);
|
|
1857
1889
|
if (remoteUrl) {
|
|
1858
1890
|
const canonicalId = normaliseGitRemoteUrl(remoteUrl);
|
|
1891
|
+
const repoRoot = getGitTopLevel(directory) ?? directory;
|
|
1859
1892
|
return {
|
|
1860
1893
|
canonical_id: canonicalId,
|
|
1861
1894
|
name: projectNameFromCanonicalId(canonicalId),
|
|
1862
1895
|
remote_url: remoteUrl,
|
|
1863
|
-
local_path:
|
|
1896
|
+
local_path: repoRoot
|
|
1864
1897
|
};
|
|
1865
1898
|
}
|
|
1866
1899
|
const configFile = readProjectConfigFile(directory);
|
|
@@ -1880,6 +1913,32 @@ function detectProject(directory) {
|
|
|
1880
1913
|
local_path: directory
|
|
1881
1914
|
};
|
|
1882
1915
|
}
|
|
1916
|
+
function detectProjectForPath(filePath, fallbackCwd) {
|
|
1917
|
+
const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
|
|
1918
|
+
const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
|
|
1919
|
+
const detected = detectProject(candidateDir);
|
|
1920
|
+
if (detected.canonical_id.startsWith("local/"))
|
|
1921
|
+
return null;
|
|
1922
|
+
return detected;
|
|
1923
|
+
}
|
|
1924
|
+
function detectProjectFromTouchedPaths(paths, fallbackCwd) {
|
|
1925
|
+
const counts = new Map;
|
|
1926
|
+
for (const rawPath of paths) {
|
|
1927
|
+
if (!rawPath || !rawPath.trim())
|
|
1928
|
+
continue;
|
|
1929
|
+
const detected = detectProjectForPath(rawPath, fallbackCwd);
|
|
1930
|
+
if (!detected)
|
|
1931
|
+
continue;
|
|
1932
|
+
const existing = counts.get(detected.canonical_id);
|
|
1933
|
+
if (existing) {
|
|
1934
|
+
existing.count += 1;
|
|
1935
|
+
} else {
|
|
1936
|
+
counts.set(detected.canonical_id, { project: detected, count: 1 });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
|
|
1940
|
+
return ranked[0]?.project ?? detectProject(fallbackCwd);
|
|
1941
|
+
}
|
|
1883
1942
|
|
|
1884
1943
|
// src/embeddings/embedder.ts
|
|
1885
1944
|
var _available = null;
|
|
@@ -2147,7 +2206,8 @@ async function saveObservation(db, config, input) {
|
|
|
2147
2206
|
return { success: false, reason: "Title is required" };
|
|
2148
2207
|
}
|
|
2149
2208
|
const cwd = input.cwd ?? process.cwd();
|
|
2150
|
-
const
|
|
2209
|
+
const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
|
|
2210
|
+
const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
|
|
2151
2211
|
const project = db.upsertProject({
|
|
2152
2212
|
canonical_id: detected.canonical_id,
|
|
2153
2213
|
name: detected.name,
|
|
@@ -2599,6 +2659,7 @@ ${eventXml}`;
|
|
|
2599
2659
|
const queryOptions = {
|
|
2600
2660
|
model: options?.model ?? "haiku",
|
|
2601
2661
|
maxTurns: 1,
|
|
2662
|
+
timeout: options?.timeoutMs ?? 800,
|
|
2602
2663
|
disallowedTools: [
|
|
2603
2664
|
"Bash",
|
|
2604
2665
|
"Read",
|
|
@@ -2919,43 +2980,37 @@ function checkSessionFatigue(db, sessionId) {
|
|
|
2919
2980
|
|
|
2920
2981
|
// hooks/post-tool-use.ts
|
|
2921
2982
|
async function main() {
|
|
2922
|
-
const
|
|
2923
|
-
if (!
|
|
2983
|
+
const raw = await readStdin();
|
|
2984
|
+
if (!raw.trim())
|
|
2924
2985
|
process.exit(0);
|
|
2925
2986
|
const boot = bootstrapHook("post-tool-use");
|
|
2926
2987
|
if (!boot)
|
|
2927
2988
|
process.exit(0);
|
|
2928
2989
|
const { config, db } = boot;
|
|
2990
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2991
|
+
let event = null;
|
|
2992
|
+
try {
|
|
2993
|
+
event = JSON.parse(raw);
|
|
2994
|
+
} catch {
|
|
2995
|
+
db.setSyncState("hook_post_tool_last_seen_epoch", String(now));
|
|
2996
|
+
db.setSyncState("hook_post_tool_last_parse_status", "invalid-json");
|
|
2997
|
+
db.setSyncState("hook_post_tool_last_payload_preview", truncatePreview(raw, 400) ?? "invalid");
|
|
2998
|
+
db.close();
|
|
2999
|
+
process.exit(0);
|
|
3000
|
+
}
|
|
3001
|
+
db.setSyncState("hook_post_tool_last_seen_epoch", String(now));
|
|
3002
|
+
db.setSyncState("hook_post_tool_last_parse_status", "parsed");
|
|
3003
|
+
db.setSyncState("hook_post_tool_last_tool_name", event.tool_name ?? "(unknown)");
|
|
3004
|
+
db.setSyncState("hook_post_tool_last_payload_preview", truncatePreview(raw, 400) ?? "parsed");
|
|
2929
3005
|
try {
|
|
2930
3006
|
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
|
-
});
|
|
3007
|
+
persistRawToolChronology(event, config.user_id, config.device_id);
|
|
2953
3008
|
}
|
|
2954
3009
|
const textToScan = extractScanText(event);
|
|
2955
3010
|
if (textToScan) {
|
|
2956
3011
|
const findings = scanForSecrets(textToScan, config.scrubbing.custom_patterns);
|
|
2957
3012
|
if (findings.length > 0) {
|
|
2958
|
-
const detected =
|
|
3013
|
+
const detected = detectProjectForEvent(event);
|
|
2959
3014
|
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
2960
3015
|
if (project) {
|
|
2961
3016
|
for (const finding of findings) {
|
|
@@ -3019,11 +3074,12 @@ async function main() {
|
|
|
3019
3074
|
}
|
|
3020
3075
|
}
|
|
3021
3076
|
let saved = false;
|
|
3022
|
-
if (config.observer?.enabled !== false) {
|
|
3077
|
+
if (shouldRunInlineObserver(event, config.observer?.enabled !== false)) {
|
|
3023
3078
|
try {
|
|
3024
|
-
const observed = await observeToolEvent(event, {
|
|
3025
|
-
model: config.observer.model
|
|
3026
|
-
|
|
3079
|
+
const observed = await withTimeout(observeToolEvent(event, {
|
|
3080
|
+
model: config.observer.model,
|
|
3081
|
+
timeoutMs: 800
|
|
3082
|
+
}), 1000);
|
|
3027
3083
|
if (observed) {
|
|
3028
3084
|
await saveObservation(db, config, observed);
|
|
3029
3085
|
incrementObserverSaveCount(event.session_id);
|
|
@@ -3050,6 +3106,76 @@ async function main() {
|
|
|
3050
3106
|
db.close();
|
|
3051
3107
|
}
|
|
3052
3108
|
}
|
|
3109
|
+
function persistRawToolChronology(event, userId, deviceId) {
|
|
3110
|
+
const rawDb = new MemDatabase(getDbPath());
|
|
3111
|
+
try {
|
|
3112
|
+
const detected = detectProjectForEvent(event);
|
|
3113
|
+
const project = rawDb.upsertProject({
|
|
3114
|
+
canonical_id: detected.canonical_id,
|
|
3115
|
+
name: detected.name,
|
|
3116
|
+
local_path: detected.local_path,
|
|
3117
|
+
remote_url: detected.remote_url ?? null
|
|
3118
|
+
});
|
|
3119
|
+
rawDb.upsertSession(event.session_id, project.id, userId, deviceId, "claude-code");
|
|
3120
|
+
const metricsIncrement = {
|
|
3121
|
+
toolCalls: 1
|
|
3122
|
+
};
|
|
3123
|
+
if ((event.tool_name === "Edit" || event.tool_name === "Write") && event.tool_input["file_path"]) {
|
|
3124
|
+
metricsIncrement.files = 1;
|
|
3125
|
+
}
|
|
3126
|
+
rawDb.incrementSessionMetrics(event.session_id, metricsIncrement);
|
|
3127
|
+
rawDb.insertToolEvent({
|
|
3128
|
+
session_id: event.session_id,
|
|
3129
|
+
project_id: project.id,
|
|
3130
|
+
tool_name: event.tool_name,
|
|
3131
|
+
tool_input_json: safeSerializeToolInput(event.tool_input),
|
|
3132
|
+
tool_response_preview: truncatePreview(event.tool_response, 1200),
|
|
3133
|
+
file_path: extractFilePath(event.tool_input),
|
|
3134
|
+
command: extractCommand(event.tool_input),
|
|
3135
|
+
user_id: userId,
|
|
3136
|
+
device_id: deviceId,
|
|
3137
|
+
agent: "claude-code"
|
|
3138
|
+
});
|
|
3139
|
+
} finally {
|
|
3140
|
+
rawDb.close();
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
async function withTimeout(promise, timeoutMs) {
|
|
3144
|
+
let timeoutId = null;
|
|
3145
|
+
try {
|
|
3146
|
+
return await Promise.race([
|
|
3147
|
+
promise,
|
|
3148
|
+
new Promise((resolve2) => {
|
|
3149
|
+
timeoutId = setTimeout(() => resolve2(null), timeoutMs);
|
|
3150
|
+
})
|
|
3151
|
+
]);
|
|
3152
|
+
} finally {
|
|
3153
|
+
if (timeoutId)
|
|
3154
|
+
clearTimeout(timeoutId);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
function shouldRunInlineObserver(event, observerEnabled) {
|
|
3158
|
+
if (!observerEnabled)
|
|
3159
|
+
return false;
|
|
3160
|
+
return event.tool_name === "Bash" || event.tool_name.startsWith("mcp__");
|
|
3161
|
+
}
|
|
3162
|
+
function detectProjectForEvent(event) {
|
|
3163
|
+
const touchedPaths = extractTouchedPaths(event);
|
|
3164
|
+
return touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, event.cwd) : detectProject(event.cwd);
|
|
3165
|
+
}
|
|
3166
|
+
function extractTouchedPaths(event) {
|
|
3167
|
+
const paths = [];
|
|
3168
|
+
const filePath = event.tool_input["file_path"];
|
|
3169
|
+
if (typeof filePath === "string" && filePath.trim().length > 0) {
|
|
3170
|
+
paths.push(filePath);
|
|
3171
|
+
}
|
|
3172
|
+
const extracted = extractObservation(event);
|
|
3173
|
+
if (extracted?.files_read)
|
|
3174
|
+
paths.push(...extracted.files_read);
|
|
3175
|
+
if (extracted?.files_modified)
|
|
3176
|
+
paths.push(...extracted.files_modified);
|
|
3177
|
+
return paths;
|
|
3178
|
+
}
|
|
3053
3179
|
function safeSerializeToolInput(toolInput) {
|
|
3054
3180
|
try {
|
|
3055
3181
|
const raw = JSON.stringify(toolInput);
|
|
@@ -657,6 +657,11 @@ import { createHash as createHash2 } from "node:crypto";
|
|
|
657
657
|
var IS_BUN = typeof globalThis.Bun !== "undefined";
|
|
658
658
|
function openDatabase(dbPath) {
|
|
659
659
|
if (IS_BUN) {
|
|
660
|
+
if (process.platform === "darwin") {
|
|
661
|
+
try {
|
|
662
|
+
return openNodeDatabase(dbPath);
|
|
663
|
+
} catch {}
|
|
664
|
+
}
|
|
660
665
|
return openBunDatabase(dbPath);
|
|
661
666
|
}
|
|
662
667
|
return openNodeDatabase(dbPath);
|
|
@@ -773,6 +778,15 @@ class MemDatabase {
|
|
|
773
778
|
}
|
|
774
779
|
return row;
|
|
775
780
|
}
|
|
781
|
+
reassignObservationProject(observationId, projectId) {
|
|
782
|
+
const existing = this.getObservationById(observationId);
|
|
783
|
+
if (!existing)
|
|
784
|
+
return false;
|
|
785
|
+
if (existing.project_id === projectId)
|
|
786
|
+
return true;
|
|
787
|
+
this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
776
790
|
getObservationById(id) {
|
|
777
791
|
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
778
792
|
}
|
|
@@ -906,8 +920,13 @@ class MemDatabase {
|
|
|
906
920
|
}
|
|
907
921
|
upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
|
|
908
922
|
const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
909
|
-
if (existing)
|
|
923
|
+
if (existing) {
|
|
924
|
+
if (existing.project_id === null && projectId !== null) {
|
|
925
|
+
this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
|
|
926
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
927
|
+
}
|
|
910
928
|
return existing;
|
|
929
|
+
}
|
|
911
930
|
const now = Math.floor(Date.now() / 1000);
|
|
912
931
|
this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
|
|
913
932
|
VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
|
|
@@ -1198,7 +1217,7 @@ function hashPrompt(prompt) {
|
|
|
1198
1217
|
// src/storage/projects.ts
|
|
1199
1218
|
import { execSync } from "node:child_process";
|
|
1200
1219
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1201
|
-
import { basename, join as join2 } from "node:path";
|
|
1220
|
+
import { basename, dirname, join as join2, resolve } from "node:path";
|
|
1202
1221
|
function normaliseGitRemoteUrl(remoteUrl) {
|
|
1203
1222
|
let url = remoteUrl.trim();
|
|
1204
1223
|
url = url.replace(/^(?:https?|ssh|git):\/\//, "");
|
|
@@ -1252,6 +1271,19 @@ function getGitRemoteUrl(directory) {
|
|
|
1252
1271
|
}
|
|
1253
1272
|
}
|
|
1254
1273
|
}
|
|
1274
|
+
function getGitTopLevel(directory) {
|
|
1275
|
+
try {
|
|
1276
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
1277
|
+
cwd: directory,
|
|
1278
|
+
encoding: "utf-8",
|
|
1279
|
+
timeout: 5000,
|
|
1280
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1281
|
+
}).trim();
|
|
1282
|
+
return root || null;
|
|
1283
|
+
} catch {
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1255
1287
|
function readProjectConfigFile(directory) {
|
|
1256
1288
|
const configPath = join2(directory, ".engrm.json");
|
|
1257
1289
|
if (!existsSync2(configPath))
|
|
@@ -1274,11 +1306,12 @@ function detectProject(directory) {
|
|
|
1274
1306
|
const remoteUrl = getGitRemoteUrl(directory);
|
|
1275
1307
|
if (remoteUrl) {
|
|
1276
1308
|
const canonicalId = normaliseGitRemoteUrl(remoteUrl);
|
|
1309
|
+
const repoRoot = getGitTopLevel(directory) ?? directory;
|
|
1277
1310
|
return {
|
|
1278
1311
|
canonical_id: canonicalId,
|
|
1279
1312
|
name: projectNameFromCanonicalId(canonicalId),
|
|
1280
1313
|
remote_url: remoteUrl,
|
|
1281
|
-
local_path:
|
|
1314
|
+
local_path: repoRoot
|
|
1282
1315
|
};
|
|
1283
1316
|
}
|
|
1284
1317
|
const configFile = readProjectConfigFile(directory);
|
|
@@ -1298,6 +1331,32 @@ function detectProject(directory) {
|
|
|
1298
1331
|
local_path: directory
|
|
1299
1332
|
};
|
|
1300
1333
|
}
|
|
1334
|
+
function detectProjectForPath(filePath, fallbackCwd) {
|
|
1335
|
+
const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
|
|
1336
|
+
const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
|
|
1337
|
+
const detected = detectProject(candidateDir);
|
|
1338
|
+
if (detected.canonical_id.startsWith("local/"))
|
|
1339
|
+
return null;
|
|
1340
|
+
return detected;
|
|
1341
|
+
}
|
|
1342
|
+
function detectProjectFromTouchedPaths(paths, fallbackCwd) {
|
|
1343
|
+
const counts = new Map;
|
|
1344
|
+
for (const rawPath of paths) {
|
|
1345
|
+
if (!rawPath || !rawPath.trim())
|
|
1346
|
+
continue;
|
|
1347
|
+
const detected = detectProjectForPath(rawPath, fallbackCwd);
|
|
1348
|
+
if (!detected)
|
|
1349
|
+
continue;
|
|
1350
|
+
const existing = counts.get(detected.canonical_id);
|
|
1351
|
+
if (existing) {
|
|
1352
|
+
existing.count += 1;
|
|
1353
|
+
} else {
|
|
1354
|
+
counts.set(detected.canonical_id, { project: detected, count: 1 });
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
|
|
1358
|
+
return ranked[0]?.project ?? detectProject(fallbackCwd);
|
|
1359
|
+
}
|
|
1301
1360
|
|
|
1302
1361
|
// src/capture/dedup.ts
|
|
1303
1362
|
function tokenise(text) {
|
|
@@ -1543,6 +1602,32 @@ function computeObservationPriority(obs, nowEpoch) {
|
|
|
1543
1602
|
}
|
|
1544
1603
|
|
|
1545
1604
|
// src/context/inject.ts
|
|
1605
|
+
function tokenizeProjectHint(text) {
|
|
1606
|
+
return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
|
|
1607
|
+
}
|
|
1608
|
+
function isObservationRelatedToProject(obs, detected) {
|
|
1609
|
+
const hints = new Set([
|
|
1610
|
+
...tokenizeProjectHint(detected.name),
|
|
1611
|
+
...tokenizeProjectHint(detected.canonical_id)
|
|
1612
|
+
]);
|
|
1613
|
+
if (hints.size === 0)
|
|
1614
|
+
return false;
|
|
1615
|
+
const haystack = [
|
|
1616
|
+
obs.title,
|
|
1617
|
+
obs.narrative ?? "",
|
|
1618
|
+
obs.facts ?? "",
|
|
1619
|
+
obs.concepts ?? "",
|
|
1620
|
+
obs.files_read ?? "",
|
|
1621
|
+
obs.files_modified ?? "",
|
|
1622
|
+
obs._source_project ?? ""
|
|
1623
|
+
].join(`
|
|
1624
|
+
`).toLowerCase();
|
|
1625
|
+
for (const hint of hints) {
|
|
1626
|
+
if (haystack.includes(hint))
|
|
1627
|
+
return true;
|
|
1628
|
+
}
|
|
1629
|
+
return false;
|
|
1630
|
+
}
|
|
1546
1631
|
function estimateTokens(text) {
|
|
1547
1632
|
if (!text)
|
|
1548
1633
|
return 0;
|
|
@@ -1618,6 +1703,9 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1618
1703
|
}
|
|
1619
1704
|
return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
|
|
1620
1705
|
});
|
|
1706
|
+
if (isNewProject) {
|
|
1707
|
+
crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
|
|
1708
|
+
}
|
|
1621
1709
|
}
|
|
1622
1710
|
const seenIds = new Set(pinned.map((o) => o.id));
|
|
1623
1711
|
const dedupedRecent = recent.filter((o) => {
|
|
@@ -1648,6 +1736,7 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1648
1736
|
const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
|
|
1649
1737
|
const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
|
|
1650
1738
|
const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
|
|
1739
|
+
const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
|
|
1651
1740
|
return {
|
|
1652
1741
|
project_name: projectName,
|
|
1653
1742
|
canonical_id: canonicalId,
|
|
@@ -1657,7 +1746,8 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1657
1746
|
recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
|
|
1658
1747
|
recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
|
|
1659
1748
|
recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
|
|
1660
|
-
projectTypeCounts: projectTypeCounts2
|
|
1749
|
+
projectTypeCounts: projectTypeCounts2,
|
|
1750
|
+
recentOutcomes: recentOutcomes2
|
|
1661
1751
|
};
|
|
1662
1752
|
}
|
|
1663
1753
|
let remainingBudget = tokenBudget - 30;
|
|
@@ -1684,6 +1774,7 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1684
1774
|
const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
|
|
1685
1775
|
const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
|
|
1686
1776
|
const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
|
|
1777
|
+
const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
|
|
1687
1778
|
let securityFindings = [];
|
|
1688
1779
|
if (!isNewProject) {
|
|
1689
1780
|
try {
|
|
@@ -1741,7 +1832,8 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1741
1832
|
recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
|
|
1742
1833
|
recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
|
|
1743
1834
|
recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
|
|
1744
|
-
projectTypeCounts
|
|
1835
|
+
projectTypeCounts,
|
|
1836
|
+
recentOutcomes
|
|
1745
1837
|
};
|
|
1746
1838
|
}
|
|
1747
1839
|
function estimateObservationTokens(obs, index) {
|
|
@@ -1778,12 +1870,15 @@ function formatContextForInjection(context) {
|
|
|
1778
1870
|
lines.push("");
|
|
1779
1871
|
}
|
|
1780
1872
|
if (context.recentPrompts && context.recentPrompts.length > 0) {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1873
|
+
const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
|
|
1874
|
+
if (promptLines.length > 0) {
|
|
1875
|
+
lines.push("## Recent Requests");
|
|
1876
|
+
for (const prompt of promptLines) {
|
|
1877
|
+
const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
|
|
1878
|
+
lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
|
|
1879
|
+
}
|
|
1880
|
+
lines.push("");
|
|
1785
1881
|
}
|
|
1786
|
-
lines.push("");
|
|
1787
1882
|
}
|
|
1788
1883
|
if (context.recentToolEvents && context.recentToolEvents.length > 0) {
|
|
1789
1884
|
lines.push("## Recent Tools");
|
|
@@ -1793,10 +1888,22 @@ function formatContextForInjection(context) {
|
|
|
1793
1888
|
lines.push("");
|
|
1794
1889
|
}
|
|
1795
1890
|
if (context.recentSessions && context.recentSessions.length > 0) {
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1891
|
+
const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
|
|
1892
|
+
const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
|
|
1893
|
+
if (summary === "(no summary)")
|
|
1894
|
+
return null;
|
|
1895
|
+
return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
|
|
1896
|
+
}).filter((line) => Boolean(line));
|
|
1897
|
+
if (recentSessionLines.length > 0) {
|
|
1898
|
+
lines.push("## Recent Sessions");
|
|
1899
|
+
lines.push(...recentSessionLines);
|
|
1900
|
+
lines.push("");
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
if (context.recentOutcomes && context.recentOutcomes.length > 0) {
|
|
1904
|
+
lines.push("## Recent Outcomes");
|
|
1905
|
+
for (const outcome of context.recentOutcomes.slice(0, 5)) {
|
|
1906
|
+
lines.push(`- ${truncateText(outcome, 160)}`);
|
|
1800
1907
|
}
|
|
1801
1908
|
lines.push("");
|
|
1802
1909
|
}
|
|
@@ -1876,6 +1983,14 @@ function formatSessionBrief(summary) {
|
|
|
1876
1983
|
}
|
|
1877
1984
|
return lines;
|
|
1878
1985
|
}
|
|
1986
|
+
function chooseMeaningfulSessionHeadline(request, completed) {
|
|
1987
|
+
if (request && !looksLikeFileOperationTitle(request))
|
|
1988
|
+
return request;
|
|
1989
|
+
const completedItems = extractMeaningfulLines(completed, 1);
|
|
1990
|
+
if (completedItems.length > 0)
|
|
1991
|
+
return completedItems[0];
|
|
1992
|
+
return request ?? completed ?? "(no summary)";
|
|
1993
|
+
}
|
|
1879
1994
|
function formatSummarySection(value, maxLen) {
|
|
1880
1995
|
if (!value)
|
|
1881
1996
|
return null;
|
|
@@ -1900,6 +2015,26 @@ function truncateText(text, maxLen) {
|
|
|
1900
2015
|
return text;
|
|
1901
2016
|
return text.slice(0, maxLen - 3) + "...";
|
|
1902
2017
|
}
|
|
2018
|
+
function isMeaningfulPrompt(value) {
|
|
2019
|
+
if (!value)
|
|
2020
|
+
return false;
|
|
2021
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
2022
|
+
if (compact.length < 8)
|
|
2023
|
+
return false;
|
|
2024
|
+
return /[a-z]{3,}/i.test(compact);
|
|
2025
|
+
}
|
|
2026
|
+
function looksLikeFileOperationTitle(value) {
|
|
2027
|
+
return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
|
|
2028
|
+
}
|
|
2029
|
+
function stripInlineSectionLabel(value) {
|
|
2030
|
+
return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
|
|
2031
|
+
}
|
|
2032
|
+
function extractMeaningfulLines(value, limit) {
|
|
2033
|
+
if (!value)
|
|
2034
|
+
return [];
|
|
2035
|
+
return value.split(`
|
|
2036
|
+
`).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);
|
|
2037
|
+
}
|
|
1903
2038
|
function formatObservationDetailFromContext(obs) {
|
|
1904
2039
|
if (obs.facts) {
|
|
1905
2040
|
const bullets = parseFacts(obs.facts);
|
|
@@ -1999,6 +2134,50 @@ function getProjectTypeCounts(db, projectId, userId) {
|
|
|
1999
2134
|
}
|
|
2000
2135
|
return counts;
|
|
2001
2136
|
}
|
|
2137
|
+
function getRecentOutcomes(db, projectId, userId) {
|
|
2138
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
2139
|
+
const visibilityParams = userId ? [userId] : [];
|
|
2140
|
+
const summaries = db.db.query(`SELECT * FROM session_summaries
|
|
2141
|
+
WHERE project_id = ?
|
|
2142
|
+
ORDER BY created_at_epoch DESC
|
|
2143
|
+
LIMIT 6`).all(projectId);
|
|
2144
|
+
const picked = [];
|
|
2145
|
+
const seen = new Set;
|
|
2146
|
+
for (const summary of summaries) {
|
|
2147
|
+
for (const line of [
|
|
2148
|
+
...extractMeaningfulLines(summary.completed, 2),
|
|
2149
|
+
...extractMeaningfulLines(summary.learned, 1)
|
|
2150
|
+
]) {
|
|
2151
|
+
const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2152
|
+
if (!normalized || seen.has(normalized))
|
|
2153
|
+
continue;
|
|
2154
|
+
seen.add(normalized);
|
|
2155
|
+
picked.push(line);
|
|
2156
|
+
if (picked.length >= 5)
|
|
2157
|
+
return picked;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
const rows = db.db.query(`SELECT * FROM observations
|
|
2161
|
+
WHERE project_id = ?
|
|
2162
|
+
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
2163
|
+
AND superseded_by IS NULL
|
|
2164
|
+
${visibilityClause}
|
|
2165
|
+
ORDER BY created_at_epoch DESC
|
|
2166
|
+
LIMIT 20`).all(projectId, ...visibilityParams);
|
|
2167
|
+
for (const obs of rows) {
|
|
2168
|
+
if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
|
|
2169
|
+
continue;
|
|
2170
|
+
const title = stripInlineSectionLabel(obs.title);
|
|
2171
|
+
const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2172
|
+
if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle(title))
|
|
2173
|
+
continue;
|
|
2174
|
+
seen.add(normalized);
|
|
2175
|
+
picked.push(title);
|
|
2176
|
+
if (picked.length >= 5)
|
|
2177
|
+
break;
|
|
2178
|
+
}
|
|
2179
|
+
return picked;
|
|
2180
|
+
}
|
|
2002
2181
|
|
|
2003
2182
|
// hooks/pre-compact.ts
|
|
2004
2183
|
function formatCurrentSessionContext(observations) {
|