engrm 0.4.44 → 0.4.46
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 +396 -118
- package/dist/hooks/elicitation-result.js +81 -15
- package/dist/hooks/post-tool-use.js +250 -23
- package/dist/hooks/pre-compact.js +249 -23
- package/dist/hooks/sentinel.js +81 -15
- package/dist/hooks/session-start.js +105 -17
- package/dist/hooks/stop.js +311 -27
- package/dist/hooks/user-prompt-submit.js +81 -1521
- package/dist/server.js +193 -34
- package/package.json +1 -1
|
@@ -153,11 +153,20 @@ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, wr
|
|
|
153
153
|
import { homedir, hostname, networkInterfaces } from "node:os";
|
|
154
154
|
import { join as join2 } from "node:path";
|
|
155
155
|
import { createHash } from "node:crypto";
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
function resolveConfigDir() {
|
|
157
|
+
return process.env["ENGRM_CONFIG_DIR"]?.trim() || join2(homedir(), ".engrm");
|
|
158
|
+
}
|
|
159
|
+
function resolveSettingsPath() {
|
|
160
|
+
return join2(resolveConfigDir(), "settings.json");
|
|
161
|
+
}
|
|
162
|
+
function resolveDbPath() {
|
|
163
|
+
return join2(resolveConfigDir(), "engrm.db");
|
|
164
|
+
}
|
|
165
|
+
function resolveAuthBackupPath() {
|
|
166
|
+
return join2(resolveConfigDir(), "auth-backup.json");
|
|
167
|
+
}
|
|
159
168
|
function getDbPath() {
|
|
160
|
-
return
|
|
169
|
+
return resolveDbPath();
|
|
161
170
|
}
|
|
162
171
|
function generateDeviceId() {
|
|
163
172
|
const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
@@ -180,7 +189,7 @@ function generateDeviceId() {
|
|
|
180
189
|
return `${host}-${suffix}`;
|
|
181
190
|
}
|
|
182
191
|
function createDefaultConfig() {
|
|
183
|
-
|
|
192
|
+
const merged = {
|
|
184
193
|
candengo_url: "",
|
|
185
194
|
candengo_api_key: "",
|
|
186
195
|
site_id: "",
|
|
@@ -235,24 +244,26 @@ function createDefaultConfig() {
|
|
|
235
244
|
},
|
|
236
245
|
tool_profile: "full"
|
|
237
246
|
};
|
|
247
|
+
return merged;
|
|
238
248
|
}
|
|
239
249
|
function loadConfig() {
|
|
240
|
-
|
|
241
|
-
|
|
250
|
+
const settingsPath = resolveSettingsPath();
|
|
251
|
+
if (!existsSync2(settingsPath)) {
|
|
252
|
+
throw new Error(`Config not found at ${settingsPath}. Run 'engrm init --manual' to configure.`);
|
|
242
253
|
}
|
|
243
|
-
const raw = readFileSync2(
|
|
254
|
+
const raw = readFileSync2(settingsPath, "utf-8");
|
|
244
255
|
let parsed;
|
|
245
256
|
try {
|
|
246
257
|
parsed = JSON.parse(raw);
|
|
247
258
|
} catch {
|
|
248
|
-
throw new Error(`Invalid JSON in ${
|
|
259
|
+
throw new Error(`Invalid JSON in ${settingsPath}`);
|
|
249
260
|
}
|
|
250
261
|
if (typeof parsed !== "object" || parsed === null) {
|
|
251
|
-
throw new Error(`Config at ${
|
|
262
|
+
throw new Error(`Config at ${settingsPath} is not a JSON object`);
|
|
252
263
|
}
|
|
253
264
|
const config = parsed;
|
|
254
265
|
const defaults = createDefaultConfig();
|
|
255
|
-
|
|
266
|
+
const merged = {
|
|
256
267
|
candengo_url: asString(config["candengo_url"], defaults.candengo_url),
|
|
257
268
|
candengo_api_key: asString(config["candengo_api_key"], defaults.candengo_api_key),
|
|
258
269
|
site_id: asString(config["site_id"], defaults.site_id),
|
|
@@ -307,16 +318,27 @@ function loadConfig() {
|
|
|
307
318
|
},
|
|
308
319
|
tool_profile: asToolProfile(config["tool_profile"], defaults.tool_profile)
|
|
309
320
|
};
|
|
321
|
+
if (looksLikePlaceholderAuth(merged)) {
|
|
322
|
+
return restoreAuthBackup(merged) ?? merged;
|
|
323
|
+
}
|
|
324
|
+
return merged;
|
|
310
325
|
}
|
|
311
326
|
function saveConfig(config) {
|
|
312
|
-
|
|
313
|
-
|
|
327
|
+
const configDir = resolveConfigDir();
|
|
328
|
+
const settingsPath = resolveSettingsPath();
|
|
329
|
+
const authBackupPath = resolveAuthBackupPath();
|
|
330
|
+
if (!existsSync2(configDir)) {
|
|
331
|
+
mkdirSync(configDir, { recursive: true });
|
|
314
332
|
}
|
|
315
|
-
writeFileSync(
|
|
333
|
+
writeFileSync(settingsPath, JSON.stringify(config, null, 2) + `
|
|
316
334
|
`, "utf-8");
|
|
335
|
+
if (!looksLikePlaceholderAuth(config)) {
|
|
336
|
+
writeFileSync(authBackupPath, JSON.stringify(extractAuthBackup(config), null, 2) + `
|
|
337
|
+
`, "utf-8");
|
|
338
|
+
}
|
|
317
339
|
}
|
|
318
340
|
function configExists() {
|
|
319
|
-
return existsSync2(
|
|
341
|
+
return existsSync2(resolveSettingsPath());
|
|
320
342
|
}
|
|
321
343
|
function asString(value, fallback) {
|
|
322
344
|
return typeof value === "string" ? value : fallback;
|
|
@@ -370,6 +392,50 @@ function asTeams(value, fallback) {
|
|
|
370
392
|
return fallback;
|
|
371
393
|
return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
|
|
372
394
|
}
|
|
395
|
+
function looksLikePlaceholderAuth(config) {
|
|
396
|
+
const apiKey = config.candengo_api_key.trim();
|
|
397
|
+
const siteId = config.site_id.trim();
|
|
398
|
+
const namespace = config.namespace.trim();
|
|
399
|
+
const email = config.user_email.trim().toLowerCase();
|
|
400
|
+
if (apiKey === "cvk_org" && siteId === "site-1" && namespace === "org-ns")
|
|
401
|
+
return true;
|
|
402
|
+
if (siteId === "site-1" && namespace === "org-ns" && email.endsWith("@example.com"))
|
|
403
|
+
return true;
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
function extractAuthBackup(config) {
|
|
407
|
+
return {
|
|
408
|
+
candengo_url: config.candengo_url,
|
|
409
|
+
candengo_api_key: config.candengo_api_key,
|
|
410
|
+
site_id: config.site_id,
|
|
411
|
+
namespace: config.namespace,
|
|
412
|
+
user_id: config.user_id,
|
|
413
|
+
user_email: config.user_email,
|
|
414
|
+
teams: config.teams
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function restoreAuthBackup(config) {
|
|
418
|
+
const authBackupPath = resolveAuthBackupPath();
|
|
419
|
+
if (!existsSync2(authBackupPath))
|
|
420
|
+
return null;
|
|
421
|
+
try {
|
|
422
|
+
const raw = readFileSync2(authBackupPath, "utf-8");
|
|
423
|
+
const parsed = JSON.parse(raw);
|
|
424
|
+
const restored = {
|
|
425
|
+
...config,
|
|
426
|
+
candengo_url: asString(parsed["candengo_url"], config.candengo_url),
|
|
427
|
+
candengo_api_key: asString(parsed["candengo_api_key"], config.candengo_api_key),
|
|
428
|
+
site_id: asString(parsed["site_id"], config.site_id),
|
|
429
|
+
namespace: asString(parsed["namespace"], config.namespace),
|
|
430
|
+
user_id: asString(parsed["user_id"], config.user_id),
|
|
431
|
+
user_email: asString(parsed["user_email"], config.user_email),
|
|
432
|
+
teams: asTeams(parsed["teams"], config.teams)
|
|
433
|
+
};
|
|
434
|
+
return looksLikePlaceholderAuth(restored) ? null : restored;
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
373
439
|
|
|
374
440
|
// src/storage/migrations.ts
|
|
375
441
|
var MIGRATIONS = [
|
|
@@ -2147,1501 +2213,6 @@ function parseJsonArray(value) {
|
|
|
2147
2213
|
}
|
|
2148
2214
|
}
|
|
2149
2215
|
|
|
2150
|
-
// src/capture/transcript.ts
|
|
2151
|
-
import { createHash as createHash3 } from "node:crypto";
|
|
2152
|
-
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
|
|
2153
|
-
import { join as join3 } from "node:path";
|
|
2154
|
-
import { homedir as homedir2 } from "node:os";
|
|
2155
|
-
|
|
2156
|
-
// src/embeddings/embedder.ts
|
|
2157
|
-
var _available = null;
|
|
2158
|
-
var _pipeline = null;
|
|
2159
|
-
var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
|
|
2160
|
-
async function embedText(text) {
|
|
2161
|
-
const pipe = await getPipeline();
|
|
2162
|
-
if (!pipe)
|
|
2163
|
-
return null;
|
|
2164
|
-
try {
|
|
2165
|
-
const output = await pipe(text, { pooling: "mean", normalize: true });
|
|
2166
|
-
return new Float32Array(output.data);
|
|
2167
|
-
} catch {
|
|
2168
|
-
return null;
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
function composeEmbeddingText(obs) {
|
|
2172
|
-
const parts = [obs.title];
|
|
2173
|
-
if (obs.narrative)
|
|
2174
|
-
parts.push(obs.narrative);
|
|
2175
|
-
if (obs.facts) {
|
|
2176
|
-
try {
|
|
2177
|
-
const facts = JSON.parse(obs.facts);
|
|
2178
|
-
if (Array.isArray(facts) && facts.length > 0) {
|
|
2179
|
-
parts.push(facts.map((f) => `- ${f}`).join(`
|
|
2180
|
-
`));
|
|
2181
|
-
}
|
|
2182
|
-
} catch {
|
|
2183
|
-
parts.push(obs.facts);
|
|
2184
|
-
}
|
|
2185
|
-
}
|
|
2186
|
-
if (obs.concepts) {
|
|
2187
|
-
try {
|
|
2188
|
-
const concepts = JSON.parse(obs.concepts);
|
|
2189
|
-
if (Array.isArray(concepts) && concepts.length > 0) {
|
|
2190
|
-
parts.push(concepts.join(", "));
|
|
2191
|
-
}
|
|
2192
|
-
} catch {}
|
|
2193
|
-
}
|
|
2194
|
-
return parts.join(`
|
|
2195
|
-
|
|
2196
|
-
`);
|
|
2197
|
-
}
|
|
2198
|
-
function composeChatEmbeddingText(text) {
|
|
2199
|
-
return text.replace(/\s+/g, " ").trim().slice(0, 2000);
|
|
2200
|
-
}
|
|
2201
|
-
async function getPipeline() {
|
|
2202
|
-
if (_pipeline)
|
|
2203
|
-
return _pipeline;
|
|
2204
|
-
if (_available === false)
|
|
2205
|
-
return null;
|
|
2206
|
-
try {
|
|
2207
|
-
const { pipeline } = await import("@xenova/transformers");
|
|
2208
|
-
_pipeline = await pipeline("feature-extraction", MODEL_NAME);
|
|
2209
|
-
_available = true;
|
|
2210
|
-
return _pipeline;
|
|
2211
|
-
} catch (err) {
|
|
2212
|
-
_available = false;
|
|
2213
|
-
console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
2214
|
-
return null;
|
|
2215
|
-
}
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
|
-
// src/tools/save.ts
|
|
2219
|
-
import { relative, isAbsolute } from "node:path";
|
|
2220
|
-
|
|
2221
|
-
// src/capture/scrubber.ts
|
|
2222
|
-
var DEFAULT_PATTERNS = [
|
|
2223
|
-
{
|
|
2224
|
-
source: "sk-[a-zA-Z0-9]{20,}",
|
|
2225
|
-
flags: "g",
|
|
2226
|
-
replacement: "[REDACTED_API_KEY]",
|
|
2227
|
-
description: "OpenAI API keys",
|
|
2228
|
-
category: "api_key",
|
|
2229
|
-
severity: "critical"
|
|
2230
|
-
},
|
|
2231
|
-
{
|
|
2232
|
-
source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
|
|
2233
|
-
flags: "g",
|
|
2234
|
-
replacement: "[REDACTED_BEARER]",
|
|
2235
|
-
description: "Bearer auth tokens",
|
|
2236
|
-
category: "token",
|
|
2237
|
-
severity: "medium"
|
|
2238
|
-
},
|
|
2239
|
-
{
|
|
2240
|
-
source: "password[=:]\\s*\\S+",
|
|
2241
|
-
flags: "gi",
|
|
2242
|
-
replacement: "password=[REDACTED]",
|
|
2243
|
-
description: "Passwords in config",
|
|
2244
|
-
category: "password",
|
|
2245
|
-
severity: "high"
|
|
2246
|
-
},
|
|
2247
|
-
{
|
|
2248
|
-
source: "postgresql://[^\\s]+",
|
|
2249
|
-
flags: "g",
|
|
2250
|
-
replacement: "[REDACTED_DB_URL]",
|
|
2251
|
-
description: "PostgreSQL connection strings",
|
|
2252
|
-
category: "db_url",
|
|
2253
|
-
severity: "high"
|
|
2254
|
-
},
|
|
2255
|
-
{
|
|
2256
|
-
source: "mongodb://[^\\s]+",
|
|
2257
|
-
flags: "g",
|
|
2258
|
-
replacement: "[REDACTED_DB_URL]",
|
|
2259
|
-
description: "MongoDB connection strings",
|
|
2260
|
-
category: "db_url",
|
|
2261
|
-
severity: "high"
|
|
2262
|
-
},
|
|
2263
|
-
{
|
|
2264
|
-
source: "mysql://[^\\s]+",
|
|
2265
|
-
flags: "g",
|
|
2266
|
-
replacement: "[REDACTED_DB_URL]",
|
|
2267
|
-
description: "MySQL connection strings",
|
|
2268
|
-
category: "db_url",
|
|
2269
|
-
severity: "high"
|
|
2270
|
-
},
|
|
2271
|
-
{
|
|
2272
|
-
source: "AKIA[A-Z0-9]{16}",
|
|
2273
|
-
flags: "g",
|
|
2274
|
-
replacement: "[REDACTED_AWS_KEY]",
|
|
2275
|
-
description: "AWS access keys",
|
|
2276
|
-
category: "api_key",
|
|
2277
|
-
severity: "critical"
|
|
2278
|
-
},
|
|
2279
|
-
{
|
|
2280
|
-
source: "ghp_[a-zA-Z0-9]{36}",
|
|
2281
|
-
flags: "g",
|
|
2282
|
-
replacement: "[REDACTED_GH_TOKEN]",
|
|
2283
|
-
description: "GitHub personal access tokens",
|
|
2284
|
-
category: "token",
|
|
2285
|
-
severity: "high"
|
|
2286
|
-
},
|
|
2287
|
-
{
|
|
2288
|
-
source: "gho_[a-zA-Z0-9]{36}",
|
|
2289
|
-
flags: "g",
|
|
2290
|
-
replacement: "[REDACTED_GH_TOKEN]",
|
|
2291
|
-
description: "GitHub OAuth tokens",
|
|
2292
|
-
category: "token",
|
|
2293
|
-
severity: "high"
|
|
2294
|
-
},
|
|
2295
|
-
{
|
|
2296
|
-
source: "github_pat_[a-zA-Z0-9_]{22,}",
|
|
2297
|
-
flags: "g",
|
|
2298
|
-
replacement: "[REDACTED_GH_TOKEN]",
|
|
2299
|
-
description: "GitHub fine-grained PATs",
|
|
2300
|
-
category: "token",
|
|
2301
|
-
severity: "high"
|
|
2302
|
-
},
|
|
2303
|
-
{
|
|
2304
|
-
source: "cvk_[a-f0-9]{64}",
|
|
2305
|
-
flags: "g",
|
|
2306
|
-
replacement: "[REDACTED_CANDENGO_KEY]",
|
|
2307
|
-
description: "Candengo API keys",
|
|
2308
|
-
category: "api_key",
|
|
2309
|
-
severity: "critical"
|
|
2310
|
-
},
|
|
2311
|
-
{
|
|
2312
|
-
source: "xox[bpras]-[a-zA-Z0-9\\-]+",
|
|
2313
|
-
flags: "g",
|
|
2314
|
-
replacement: "[REDACTED_SLACK_TOKEN]",
|
|
2315
|
-
description: "Slack tokens",
|
|
2316
|
-
category: "token",
|
|
2317
|
-
severity: "high"
|
|
2318
|
-
}
|
|
2319
|
-
];
|
|
2320
|
-
function compileCustomPatterns(patterns) {
|
|
2321
|
-
const compiled = [];
|
|
2322
|
-
for (const pattern of patterns) {
|
|
2323
|
-
try {
|
|
2324
|
-
new RegExp(pattern);
|
|
2325
|
-
compiled.push({
|
|
2326
|
-
source: pattern,
|
|
2327
|
-
flags: "g",
|
|
2328
|
-
replacement: "[REDACTED_CUSTOM]",
|
|
2329
|
-
description: `Custom pattern: ${pattern}`,
|
|
2330
|
-
category: "custom",
|
|
2331
|
-
severity: "medium"
|
|
2332
|
-
});
|
|
2333
|
-
} catch {}
|
|
2334
|
-
}
|
|
2335
|
-
return compiled;
|
|
2336
|
-
}
|
|
2337
|
-
function scrubSecrets(text, customPatterns = []) {
|
|
2338
|
-
let result = text;
|
|
2339
|
-
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
2340
|
-
for (const pattern of allPatterns) {
|
|
2341
|
-
result = result.replace(new RegExp(pattern.source, pattern.flags), pattern.replacement);
|
|
2342
|
-
}
|
|
2343
|
-
return result;
|
|
2344
|
-
}
|
|
2345
|
-
function containsSecrets(text, customPatterns = []) {
|
|
2346
|
-
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
2347
|
-
for (const pattern of allPatterns) {
|
|
2348
|
-
if (new RegExp(pattern.source, pattern.flags).test(text))
|
|
2349
|
-
return true;
|
|
2350
|
-
}
|
|
2351
|
-
return false;
|
|
2352
|
-
}
|
|
2353
|
-
var FLEET_HOSTNAME_PATTERN = /\b(?=.{1,253}\b)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\b/gi;
|
|
2354
|
-
var FLEET_IP_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
|
|
2355
|
-
var FLEET_MAC_PATTERN = /\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b/gi;
|
|
2356
|
-
function scrubFleetIdentifiers(text) {
|
|
2357
|
-
return text.replace(FLEET_MAC_PATTERN, "[REDACTED_MAC]").replace(FLEET_IP_PATTERN, "[REDACTED_IP]").replace(FLEET_HOSTNAME_PATTERN, "[REDACTED_HOSTNAME]");
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
// src/capture/quality.ts
|
|
2361
|
-
var QUALITY_THRESHOLD = 0.1;
|
|
2362
|
-
function scoreQuality(input) {
|
|
2363
|
-
let score = 0;
|
|
2364
|
-
switch (input.type) {
|
|
2365
|
-
case "bugfix":
|
|
2366
|
-
score += 0.3;
|
|
2367
|
-
break;
|
|
2368
|
-
case "decision":
|
|
2369
|
-
score += 0.3;
|
|
2370
|
-
break;
|
|
2371
|
-
case "discovery":
|
|
2372
|
-
score += 0.2;
|
|
2373
|
-
break;
|
|
2374
|
-
case "pattern":
|
|
2375
|
-
score += 0.2;
|
|
2376
|
-
break;
|
|
2377
|
-
case "feature":
|
|
2378
|
-
score += 0.15;
|
|
2379
|
-
break;
|
|
2380
|
-
case "refactor":
|
|
2381
|
-
score += 0.15;
|
|
2382
|
-
break;
|
|
2383
|
-
case "change":
|
|
2384
|
-
score += 0.05;
|
|
2385
|
-
break;
|
|
2386
|
-
case "digest":
|
|
2387
|
-
score += 0.3;
|
|
2388
|
-
break;
|
|
2389
|
-
case "standard":
|
|
2390
|
-
score += 0.25;
|
|
2391
|
-
break;
|
|
2392
|
-
case "message":
|
|
2393
|
-
score += 0.1;
|
|
2394
|
-
break;
|
|
2395
|
-
}
|
|
2396
|
-
if (input.narrative && input.narrative.length > 50) {
|
|
2397
|
-
score += 0.15;
|
|
2398
|
-
}
|
|
2399
|
-
if (input.facts) {
|
|
2400
|
-
try {
|
|
2401
|
-
const factsArray = JSON.parse(input.facts);
|
|
2402
|
-
if (factsArray.length >= 2)
|
|
2403
|
-
score += 0.15;
|
|
2404
|
-
else if (factsArray.length === 1)
|
|
2405
|
-
score += 0.05;
|
|
2406
|
-
} catch {
|
|
2407
|
-
if (input.facts.length > 20)
|
|
2408
|
-
score += 0.05;
|
|
2409
|
-
}
|
|
2410
|
-
}
|
|
2411
|
-
if (input.concepts) {
|
|
2412
|
-
try {
|
|
2413
|
-
const conceptsArray = JSON.parse(input.concepts);
|
|
2414
|
-
if (conceptsArray.length >= 1)
|
|
2415
|
-
score += 0.1;
|
|
2416
|
-
} catch {
|
|
2417
|
-
if (input.concepts.length > 10)
|
|
2418
|
-
score += 0.05;
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
const modifiedCount = input.filesModified?.length ?? 0;
|
|
2422
|
-
if (modifiedCount >= 3)
|
|
2423
|
-
score += 0.2;
|
|
2424
|
-
else if (modifiedCount >= 1)
|
|
2425
|
-
score += 0.1;
|
|
2426
|
-
if (input.isDuplicate) {
|
|
2427
|
-
score -= 0.3;
|
|
2428
|
-
}
|
|
2429
|
-
return Math.max(0, Math.min(1, score));
|
|
2430
|
-
}
|
|
2431
|
-
function meetsQualityThreshold(input) {
|
|
2432
|
-
return scoreQuality(input) >= QUALITY_THRESHOLD;
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
// src/capture/dedup.ts
|
|
2436
|
-
function tokenise(text) {
|
|
2437
|
-
const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
|
2438
|
-
const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
|
|
2439
|
-
return new Set(tokens);
|
|
2440
|
-
}
|
|
2441
|
-
function jaccardSimilarity(a, b) {
|
|
2442
|
-
const tokensA = tokenise(a);
|
|
2443
|
-
const tokensB = tokenise(b);
|
|
2444
|
-
if (tokensA.size === 0 && tokensB.size === 0)
|
|
2445
|
-
return 1;
|
|
2446
|
-
if (tokensA.size === 0 || tokensB.size === 0)
|
|
2447
|
-
return 0;
|
|
2448
|
-
let intersectionSize = 0;
|
|
2449
|
-
for (const token of tokensA) {
|
|
2450
|
-
if (tokensB.has(token))
|
|
2451
|
-
intersectionSize++;
|
|
2452
|
-
}
|
|
2453
|
-
const unionSize = tokensA.size + tokensB.size - intersectionSize;
|
|
2454
|
-
if (unionSize === 0)
|
|
2455
|
-
return 0;
|
|
2456
|
-
return intersectionSize / unionSize;
|
|
2457
|
-
}
|
|
2458
|
-
var DEDUP_THRESHOLD = 0.8;
|
|
2459
|
-
function findDuplicate(newTitle, candidates) {
|
|
2460
|
-
let bestMatch = null;
|
|
2461
|
-
let bestScore = 0;
|
|
2462
|
-
for (const candidate of candidates) {
|
|
2463
|
-
const similarity = jaccardSimilarity(newTitle, candidate.title);
|
|
2464
|
-
if (similarity > DEDUP_THRESHOLD && similarity > bestScore) {
|
|
2465
|
-
bestScore = similarity;
|
|
2466
|
-
bestMatch = candidate;
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2469
|
-
return bestMatch;
|
|
2470
|
-
}
|
|
2471
|
-
|
|
2472
|
-
// src/capture/facts.ts
|
|
2473
|
-
var FACT_ELIGIBLE_TYPES = new Set([
|
|
2474
|
-
"bugfix",
|
|
2475
|
-
"decision",
|
|
2476
|
-
"discovery",
|
|
2477
|
-
"pattern",
|
|
2478
|
-
"feature",
|
|
2479
|
-
"refactor",
|
|
2480
|
-
"change"
|
|
2481
|
-
]);
|
|
2482
|
-
function buildStructuredFacts(input) {
|
|
2483
|
-
const seedFacts = dedupeFacts(input.facts ?? []);
|
|
2484
|
-
if (!FACT_ELIGIBLE_TYPES.has(input.type)) {
|
|
2485
|
-
return seedFacts;
|
|
2486
|
-
}
|
|
2487
|
-
const derived = [...seedFacts];
|
|
2488
|
-
if (seedFacts.length === 0 && looksMeaningful(input.title)) {
|
|
2489
|
-
derived.push(input.title.trim());
|
|
2490
|
-
}
|
|
2491
|
-
for (const sentence of extractNarrativeFacts(input.narrative)) {
|
|
2492
|
-
derived.push(sentence);
|
|
2493
|
-
}
|
|
2494
|
-
const fileFact = buildFilesFact(input.filesModified);
|
|
2495
|
-
if (fileFact) {
|
|
2496
|
-
derived.push(fileFact);
|
|
2497
|
-
}
|
|
2498
|
-
return dedupeFacts(derived).slice(0, 4);
|
|
2499
|
-
}
|
|
2500
|
-
function extractNarrativeFacts(narrative) {
|
|
2501
|
-
if (!narrative)
|
|
2502
|
-
return [];
|
|
2503
|
-
const cleaned = narrative.replace(/\s+/g, " ").trim();
|
|
2504
|
-
if (cleaned.length < 24)
|
|
2505
|
-
return [];
|
|
2506
|
-
const parts = cleaned.split(/(?<=[.!?;])\s+/).map((part) => part.trim().replace(/[.!?;]+$/, "")).filter(Boolean).filter(looksMeaningful);
|
|
2507
|
-
return parts.slice(0, 2);
|
|
2508
|
-
}
|
|
2509
|
-
function buildFilesFact(filesModified) {
|
|
2510
|
-
if (!filesModified || filesModified.length === 0)
|
|
2511
|
-
return null;
|
|
2512
|
-
const cleaned = filesModified.map((file) => file.trim()).filter(Boolean).slice(0, 3);
|
|
2513
|
-
if (cleaned.length === 0)
|
|
2514
|
-
return null;
|
|
2515
|
-
if (cleaned.length === 1) {
|
|
2516
|
-
return `Touched ${cleaned[0]}`;
|
|
2517
|
-
}
|
|
2518
|
-
return `Touched ${cleaned.join(", ")}`;
|
|
2519
|
-
}
|
|
2520
|
-
function dedupeFacts(facts) {
|
|
2521
|
-
const seen = new Set;
|
|
2522
|
-
const result = [];
|
|
2523
|
-
for (const fact of facts) {
|
|
2524
|
-
const cleaned = fact.trim().replace(/\s+/g, " ");
|
|
2525
|
-
if (!looksMeaningful(cleaned))
|
|
2526
|
-
continue;
|
|
2527
|
-
const key = cleaned.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
|
|
2528
|
-
if (!key || seen.has(key))
|
|
2529
|
-
continue;
|
|
2530
|
-
seen.add(key);
|
|
2531
|
-
result.push(cleaned);
|
|
2532
|
-
}
|
|
2533
|
-
return result;
|
|
2534
|
-
}
|
|
2535
|
-
function looksMeaningful(value) {
|
|
2536
|
-
const cleaned = value.trim();
|
|
2537
|
-
if (cleaned.length < 12)
|
|
2538
|
-
return false;
|
|
2539
|
-
if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
|
|
2540
|
-
return false;
|
|
2541
|
-
if (/^(updated|modified|edited|changed|touched)\s+[A-Za-z0-9_.\-\/]+$/i.test(cleaned))
|
|
2542
|
-
return false;
|
|
2543
|
-
return true;
|
|
2544
|
-
}
|
|
2545
|
-
|
|
2546
|
-
// src/capture/recurrence.ts
|
|
2547
|
-
var DISTANCE_THRESHOLD = 0.15;
|
|
2548
|
-
async function detectRecurrence(db, config, observation) {
|
|
2549
|
-
if (observation.type !== "bugfix") {
|
|
2550
|
-
return { patternCreated: false };
|
|
2551
|
-
}
|
|
2552
|
-
if (!db.vecAvailable) {
|
|
2553
|
-
return { patternCreated: false };
|
|
2554
|
-
}
|
|
2555
|
-
const text = composeEmbeddingText(observation);
|
|
2556
|
-
const embedding = await embedText(text);
|
|
2557
|
-
if (!embedding) {
|
|
2558
|
-
return { patternCreated: false };
|
|
2559
|
-
}
|
|
2560
|
-
const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
|
|
2561
|
-
for (const match of vecResults) {
|
|
2562
|
-
if (match.observation_id === observation.id)
|
|
2563
|
-
continue;
|
|
2564
|
-
if (match.distance > DISTANCE_THRESHOLD)
|
|
2565
|
-
continue;
|
|
2566
|
-
const matched = db.getObservationById(match.observation_id);
|
|
2567
|
-
if (!matched)
|
|
2568
|
-
continue;
|
|
2569
|
-
if (matched.type !== "bugfix")
|
|
2570
|
-
continue;
|
|
2571
|
-
if (matched.session_id === observation.session_id)
|
|
2572
|
-
continue;
|
|
2573
|
-
if (await patternAlreadyExists(db, observation, matched))
|
|
2574
|
-
continue;
|
|
2575
|
-
let matchedProjectName;
|
|
2576
|
-
if (matched.project_id !== observation.project_id) {
|
|
2577
|
-
const proj = db.getProjectById(matched.project_id);
|
|
2578
|
-
if (proj)
|
|
2579
|
-
matchedProjectName = proj.name;
|
|
2580
|
-
}
|
|
2581
|
-
const similarity = 1 - match.distance;
|
|
2582
|
-
const result = await saveObservation(db, config, {
|
|
2583
|
-
type: "pattern",
|
|
2584
|
-
title: `Recurring bugfix: ${observation.title}`,
|
|
2585
|
-
narrative: `This bug pattern has appeared in multiple sessions. Original: "${matched.title}" (session ${matched.session_id?.slice(0, 8) ?? "unknown"}). Latest: "${observation.title}". Similarity: ${(similarity * 100).toFixed(0)}%. Consider addressing the root cause.`,
|
|
2586
|
-
facts: [
|
|
2587
|
-
`First seen: ${matched.created_at.split("T")[0]}`,
|
|
2588
|
-
`Recurred: ${observation.created_at.split("T")[0]}`,
|
|
2589
|
-
`Similarity: ${(similarity * 100).toFixed(0)}%`
|
|
2590
|
-
],
|
|
2591
|
-
concepts: mergeConceptsFromBoth(observation, matched),
|
|
2592
|
-
cwd: process.cwd(),
|
|
2593
|
-
session_id: observation.session_id ?? undefined
|
|
2594
|
-
});
|
|
2595
|
-
if (result.success && result.observation_id) {
|
|
2596
|
-
return {
|
|
2597
|
-
patternCreated: true,
|
|
2598
|
-
patternId: result.observation_id,
|
|
2599
|
-
matchedObservationId: matched.id,
|
|
2600
|
-
matchedProjectName,
|
|
2601
|
-
matchedTitle: matched.title,
|
|
2602
|
-
similarity
|
|
2603
|
-
};
|
|
2604
|
-
}
|
|
2605
|
-
}
|
|
2606
|
-
return { patternCreated: false };
|
|
2607
|
-
}
|
|
2608
|
-
async function patternAlreadyExists(db, obs1, obs2) {
|
|
2609
|
-
const recentPatterns = db.db.query(`SELECT * FROM observations
|
|
2610
|
-
WHERE type = 'pattern' AND lifecycle IN ('active', 'aging', 'pinned')
|
|
2611
|
-
AND title LIKE ?
|
|
2612
|
-
ORDER BY created_at_epoch DESC LIMIT 5`).all(`%${obs1.title.slice(0, 30)}%`);
|
|
2613
|
-
for (const p of recentPatterns) {
|
|
2614
|
-
if (p.narrative?.includes(obs2.title.slice(0, 30)))
|
|
2615
|
-
return true;
|
|
2616
|
-
}
|
|
2617
|
-
return false;
|
|
2618
|
-
}
|
|
2619
|
-
function mergeConceptsFromBoth(obs1, obs2) {
|
|
2620
|
-
const concepts = new Set;
|
|
2621
|
-
for (const obs of [obs1, obs2]) {
|
|
2622
|
-
if (obs.concepts) {
|
|
2623
|
-
try {
|
|
2624
|
-
const parsed = JSON.parse(obs.concepts);
|
|
2625
|
-
if (Array.isArray(parsed)) {
|
|
2626
|
-
for (const c2 of parsed) {
|
|
2627
|
-
if (typeof c2 === "string")
|
|
2628
|
-
concepts.add(c2);
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
} catch {}
|
|
2632
|
-
}
|
|
2633
|
-
}
|
|
2634
|
-
return [...concepts];
|
|
2635
|
-
}
|
|
2636
|
-
|
|
2637
|
-
// src/capture/conflict.ts
|
|
2638
|
-
var SIMILARITY_THRESHOLD = 0.25;
|
|
2639
|
-
async function detectDecisionConflict(db, observation) {
|
|
2640
|
-
if (observation.type !== "decision") {
|
|
2641
|
-
return { hasConflict: false };
|
|
2642
|
-
}
|
|
2643
|
-
if (!observation.narrative || observation.narrative.trim().length < 20) {
|
|
2644
|
-
return { hasConflict: false };
|
|
2645
|
-
}
|
|
2646
|
-
if (db.vecAvailable) {
|
|
2647
|
-
return detectViaVec(db, observation);
|
|
2648
|
-
}
|
|
2649
|
-
return detectViaFts(db, observation);
|
|
2650
|
-
}
|
|
2651
|
-
async function detectViaVec(db, observation) {
|
|
2652
|
-
const text = composeEmbeddingText(observation);
|
|
2653
|
-
const embedding = await embedText(text);
|
|
2654
|
-
if (!embedding)
|
|
2655
|
-
return { hasConflict: false };
|
|
2656
|
-
const results = db.searchVec(embedding, observation.project_id, ["active", "aging", "pinned"], 10);
|
|
2657
|
-
for (const match of results) {
|
|
2658
|
-
if (match.observation_id === observation.id)
|
|
2659
|
-
continue;
|
|
2660
|
-
if (match.distance > SIMILARITY_THRESHOLD)
|
|
2661
|
-
continue;
|
|
2662
|
-
const existing = db.getObservationById(match.observation_id);
|
|
2663
|
-
if (!existing)
|
|
2664
|
-
continue;
|
|
2665
|
-
if (existing.type !== "decision")
|
|
2666
|
-
continue;
|
|
2667
|
-
if (!existing.narrative)
|
|
2668
|
-
continue;
|
|
2669
|
-
const conflict = narrativesConflict(observation.narrative, existing.narrative);
|
|
2670
|
-
if (conflict) {
|
|
2671
|
-
return {
|
|
2672
|
-
hasConflict: true,
|
|
2673
|
-
conflictingId: existing.id,
|
|
2674
|
-
conflictingTitle: existing.title,
|
|
2675
|
-
reason: conflict
|
|
2676
|
-
};
|
|
2677
|
-
}
|
|
2678
|
-
}
|
|
2679
|
-
return { hasConflict: false };
|
|
2680
|
-
}
|
|
2681
|
-
async function detectViaFts(db, observation) {
|
|
2682
|
-
const keywords = observation.title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5).join(" ");
|
|
2683
|
-
if (!keywords)
|
|
2684
|
-
return { hasConflict: false };
|
|
2685
|
-
const ftsResults = db.searchFts(keywords, observation.project_id, ["active", "aging", "pinned"], 10);
|
|
2686
|
-
for (const match of ftsResults) {
|
|
2687
|
-
if (match.id === observation.id)
|
|
2688
|
-
continue;
|
|
2689
|
-
const existing = db.getObservationById(match.id);
|
|
2690
|
-
if (!existing)
|
|
2691
|
-
continue;
|
|
2692
|
-
if (existing.type !== "decision")
|
|
2693
|
-
continue;
|
|
2694
|
-
if (!existing.narrative)
|
|
2695
|
-
continue;
|
|
2696
|
-
const conflict = narrativesConflict(observation.narrative, existing.narrative);
|
|
2697
|
-
if (conflict) {
|
|
2698
|
-
return {
|
|
2699
|
-
hasConflict: true,
|
|
2700
|
-
conflictingId: existing.id,
|
|
2701
|
-
conflictingTitle: existing.title,
|
|
2702
|
-
reason: conflict
|
|
2703
|
-
};
|
|
2704
|
-
}
|
|
2705
|
-
}
|
|
2706
|
-
return { hasConflict: false };
|
|
2707
|
-
}
|
|
2708
|
-
function narrativesConflict(narrative1, narrative2) {
|
|
2709
|
-
const n1 = narrative1.toLowerCase();
|
|
2710
|
-
const n2 = narrative2.toLowerCase();
|
|
2711
|
-
const opposingPairs = [
|
|
2712
|
-
[["should use", "decided to use", "chose", "prefer", "went with"], ["should not", "decided against", "avoid", "rejected", "don't use"]],
|
|
2713
|
-
[["enable", "turn on", "activate", "add"], ["disable", "turn off", "deactivate", "remove"]],
|
|
2714
|
-
[["increase", "more", "higher", "scale up"], ["decrease", "less", "lower", "scale down"]],
|
|
2715
|
-
[["keep", "maintain", "preserve"], ["replace", "migrate", "switch from", "deprecate"]]
|
|
2716
|
-
];
|
|
2717
|
-
for (const [positive, negative] of opposingPairs) {
|
|
2718
|
-
const n1HasPositive = positive.some((w) => n1.includes(w));
|
|
2719
|
-
const n1HasNegative = negative.some((w) => n1.includes(w));
|
|
2720
|
-
const n2HasPositive = positive.some((w) => n2.includes(w));
|
|
2721
|
-
const n2HasNegative = negative.some((w) => n2.includes(w));
|
|
2722
|
-
if (n1HasPositive && n2HasNegative || n1HasNegative && n2HasPositive) {
|
|
2723
|
-
return "Narratives suggest opposing conclusions on a similar topic";
|
|
2724
|
-
}
|
|
2725
|
-
}
|
|
2726
|
-
return null;
|
|
2727
|
-
}
|
|
2728
|
-
|
|
2729
|
-
// src/sync/targets.ts
|
|
2730
|
-
function isFleetProjectName(projectName, config) {
|
|
2731
|
-
const fleetProjectName = config.fleet?.project_name ?? "shared-experience";
|
|
2732
|
-
if (!projectName || !fleetProjectName)
|
|
2733
|
-
return false;
|
|
2734
|
-
return projectName.trim().toLowerCase() === fleetProjectName.trim().toLowerCase();
|
|
2735
|
-
}
|
|
2736
|
-
function hasFleetTarget(config) {
|
|
2737
|
-
return Boolean(config.fleet?.namespace?.trim() && config.fleet?.api_key?.trim() && (config.fleet?.project_name ?? "shared-experience").trim());
|
|
2738
|
-
}
|
|
2739
|
-
function resolveSyncTarget(config, projectName) {
|
|
2740
|
-
if (isFleetProjectName(projectName, config) && hasFleetTarget(config)) {
|
|
2741
|
-
return {
|
|
2742
|
-
key: `fleet:${config.fleet.namespace}`,
|
|
2743
|
-
apiKey: config.fleet.api_key,
|
|
2744
|
-
namespace: config.fleet.namespace,
|
|
2745
|
-
siteId: config.site_id,
|
|
2746
|
-
isFleet: true
|
|
2747
|
-
};
|
|
2748
|
-
}
|
|
2749
|
-
return {
|
|
2750
|
-
key: `default:${config.namespace}`,
|
|
2751
|
-
apiKey: config.candengo_api_key,
|
|
2752
|
-
namespace: config.namespace,
|
|
2753
|
-
siteId: config.site_id,
|
|
2754
|
-
isFleet: false
|
|
2755
|
-
};
|
|
2756
|
-
}
|
|
2757
|
-
|
|
2758
|
-
// src/tools/save.ts
|
|
2759
|
-
var VALID_TYPES = [
|
|
2760
|
-
"bugfix",
|
|
2761
|
-
"discovery",
|
|
2762
|
-
"decision",
|
|
2763
|
-
"pattern",
|
|
2764
|
-
"change",
|
|
2765
|
-
"feature",
|
|
2766
|
-
"refactor",
|
|
2767
|
-
"digest",
|
|
2768
|
-
"standard",
|
|
2769
|
-
"message"
|
|
2770
|
-
];
|
|
2771
|
-
async function saveObservation(db, config, input) {
|
|
2772
|
-
if (!VALID_TYPES.includes(input.type)) {
|
|
2773
|
-
return {
|
|
2774
|
-
success: false,
|
|
2775
|
-
reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`
|
|
2776
|
-
};
|
|
2777
|
-
}
|
|
2778
|
-
if (!input.title || input.title.trim().length === 0) {
|
|
2779
|
-
return { success: false, reason: "Title is required" };
|
|
2780
|
-
}
|
|
2781
|
-
const cwd = input.cwd ?? process.cwd();
|
|
2782
|
-
const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
|
|
2783
|
-
const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
|
|
2784
|
-
const project = db.upsertProject({
|
|
2785
|
-
canonical_id: detected.canonical_id,
|
|
2786
|
-
name: detected.name,
|
|
2787
|
-
local_path: detected.local_path,
|
|
2788
|
-
remote_url: detected.remote_url
|
|
2789
|
-
});
|
|
2790
|
-
const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
|
|
2791
|
-
const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
|
|
2792
|
-
const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
|
|
2793
|
-
const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
|
|
2794
|
-
const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
|
|
2795
|
-
const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
|
|
2796
|
-
const structuredFacts = buildStructuredFacts({
|
|
2797
|
-
type: input.type,
|
|
2798
|
-
title: input.title,
|
|
2799
|
-
narrative: input.narrative,
|
|
2800
|
-
facts: input.facts,
|
|
2801
|
-
filesModified
|
|
2802
|
-
});
|
|
2803
|
-
const factsJson = structuredFacts.length > 0 ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(structuredFacts), customPatterns) : JSON.stringify(structuredFacts) : null;
|
|
2804
|
-
const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
|
|
2805
|
-
const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
|
|
2806
|
-
const fleetProject = isFleetProjectName(project.name, config);
|
|
2807
|
-
let sensitivity = input.sensitivity ?? (fleetProject ? "shared" : config.scrubbing.default_sensitivity);
|
|
2808
|
-
if (config.scrubbing.enabled && containsSecrets([input.title, input.narrative, JSON.stringify(input.facts)].filter(Boolean).join(" "), customPatterns)) {
|
|
2809
|
-
if (sensitivity === "shared") {
|
|
2810
|
-
sensitivity = "personal";
|
|
2811
|
-
}
|
|
2812
|
-
}
|
|
2813
|
-
const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
|
|
2814
|
-
const recentObs = db.getRecentObservations(project.id, oneDayAgo);
|
|
2815
|
-
const candidates = recentObs.map((o) => ({
|
|
2816
|
-
id: o.id,
|
|
2817
|
-
title: o.title
|
|
2818
|
-
}));
|
|
2819
|
-
const duplicate = findDuplicate(title, candidates);
|
|
2820
|
-
const qualityInput = {
|
|
2821
|
-
type: input.type,
|
|
2822
|
-
title,
|
|
2823
|
-
narrative,
|
|
2824
|
-
facts: factsJson,
|
|
2825
|
-
concepts: conceptsJson,
|
|
2826
|
-
filesRead,
|
|
2827
|
-
filesModified,
|
|
2828
|
-
isDuplicate: duplicate !== null
|
|
2829
|
-
};
|
|
2830
|
-
const qualityScore = scoreQuality(qualityInput);
|
|
2831
|
-
if (!meetsQualityThreshold(qualityInput)) {
|
|
2832
|
-
return {
|
|
2833
|
-
success: false,
|
|
2834
|
-
quality_score: qualityScore,
|
|
2835
|
-
reason: `Quality score ${qualityScore.toFixed(2)} below threshold`
|
|
2836
|
-
};
|
|
2837
|
-
}
|
|
2838
|
-
if (duplicate) {
|
|
2839
|
-
return {
|
|
2840
|
-
success: true,
|
|
2841
|
-
merged_into: duplicate.id,
|
|
2842
|
-
quality_score: qualityScore,
|
|
2843
|
-
reason: `Merged into existing observation #${duplicate.id}`
|
|
2844
|
-
};
|
|
2845
|
-
}
|
|
2846
|
-
const sourcePromptNumber = input.source_prompt_number ?? (input.session_id ? db.getLatestSessionPromptNumber(input.session_id) : null);
|
|
2847
|
-
const obs = db.insertObservation({
|
|
2848
|
-
session_id: input.session_id ?? null,
|
|
2849
|
-
project_id: project.id,
|
|
2850
|
-
type: input.type,
|
|
2851
|
-
title,
|
|
2852
|
-
narrative,
|
|
2853
|
-
facts: factsJson,
|
|
2854
|
-
concepts: conceptsJson,
|
|
2855
|
-
files_read: filesReadJson,
|
|
2856
|
-
files_modified: filesModifiedJson,
|
|
2857
|
-
quality: qualityScore,
|
|
2858
|
-
lifecycle: "active",
|
|
2859
|
-
sensitivity,
|
|
2860
|
-
user_id: config.user_id,
|
|
2861
|
-
device_id: config.device_id,
|
|
2862
|
-
agent: input.agent ?? "claude-code",
|
|
2863
|
-
source_tool: input.source_tool ?? null,
|
|
2864
|
-
source_prompt_number: sourcePromptNumber
|
|
2865
|
-
});
|
|
2866
|
-
db.addToOutbox("observation", obs.id);
|
|
2867
|
-
if (db.vecAvailable) {
|
|
2868
|
-
try {
|
|
2869
|
-
const text = composeEmbeddingText(obs);
|
|
2870
|
-
const embedding = await embedText(text);
|
|
2871
|
-
if (embedding) {
|
|
2872
|
-
db.vecInsert(obs.id, embedding);
|
|
2873
|
-
}
|
|
2874
|
-
} catch {}
|
|
2875
|
-
}
|
|
2876
|
-
let recallHint;
|
|
2877
|
-
if (input.type === "bugfix") {
|
|
2878
|
-
try {
|
|
2879
|
-
const recurrence = await detectRecurrence(db, config, obs);
|
|
2880
|
-
if (recurrence.patternCreated && recurrence.matchedTitle) {
|
|
2881
|
-
const projectLabel = recurrence.matchedProjectName ? ` in ${recurrence.matchedProjectName}` : "";
|
|
2882
|
-
recallHint = `You solved a similar issue${projectLabel}: "${recurrence.matchedTitle}"`;
|
|
2883
|
-
}
|
|
2884
|
-
} catch {}
|
|
2885
|
-
}
|
|
2886
|
-
let conflictWarning;
|
|
2887
|
-
if (input.type === "decision") {
|
|
2888
|
-
try {
|
|
2889
|
-
const conflict = await detectDecisionConflict(db, obs);
|
|
2890
|
-
if (conflict.hasConflict && conflict.conflictingTitle) {
|
|
2891
|
-
conflictWarning = `Potential conflict with existing decision: "${conflict.conflictingTitle}" — ${conflict.reason}`;
|
|
2892
|
-
}
|
|
2893
|
-
} catch {}
|
|
2894
|
-
}
|
|
2895
|
-
return {
|
|
2896
|
-
success: true,
|
|
2897
|
-
observation_id: obs.id,
|
|
2898
|
-
quality_score: qualityScore,
|
|
2899
|
-
recall_hint: recallHint,
|
|
2900
|
-
conflict_warning: conflictWarning
|
|
2901
|
-
};
|
|
2902
|
-
}
|
|
2903
|
-
function toRelativePath(filePath, projectRoot) {
|
|
2904
|
-
if (!isAbsolute(filePath))
|
|
2905
|
-
return filePath;
|
|
2906
|
-
const rel = relative(projectRoot, filePath);
|
|
2907
|
-
if (rel.startsWith(".."))
|
|
2908
|
-
return filePath;
|
|
2909
|
-
return rel;
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
// src/capture/transcript.ts
|
|
2913
|
-
function resolveTranscriptPath(sessionId, cwd, transcriptPath) {
|
|
2914
|
-
if (transcriptPath)
|
|
2915
|
-
return transcriptPath;
|
|
2916
|
-
const encodedCwd = cwd.replace(/\//g, "-");
|
|
2917
|
-
return join3(homedir2(), ".claude", "projects", encodedCwd, `${sessionId}.jsonl`);
|
|
2918
|
-
}
|
|
2919
|
-
function readTranscript(sessionId, cwd, transcriptPath) {
|
|
2920
|
-
const path = resolveTranscriptPath(sessionId, cwd, transcriptPath);
|
|
2921
|
-
if (!existsSync3(path))
|
|
2922
|
-
return [];
|
|
2923
|
-
let raw;
|
|
2924
|
-
try {
|
|
2925
|
-
raw = readFileSync3(path, "utf-8");
|
|
2926
|
-
} catch {
|
|
2927
|
-
return [];
|
|
2928
|
-
}
|
|
2929
|
-
const messages = [];
|
|
2930
|
-
for (const line of raw.split(`
|
|
2931
|
-
`)) {
|
|
2932
|
-
if (!line.trim())
|
|
2933
|
-
continue;
|
|
2934
|
-
let entry;
|
|
2935
|
-
try {
|
|
2936
|
-
entry = JSON.parse(line);
|
|
2937
|
-
} catch {
|
|
2938
|
-
continue;
|
|
2939
|
-
}
|
|
2940
|
-
const role = entry.role;
|
|
2941
|
-
if (role !== "user" && role !== "assistant")
|
|
2942
|
-
continue;
|
|
2943
|
-
const content = entry.content;
|
|
2944
|
-
if (typeof content === "string") {
|
|
2945
|
-
messages.push({ role, text: content });
|
|
2946
|
-
continue;
|
|
2947
|
-
}
|
|
2948
|
-
if (Array.isArray(content)) {
|
|
2949
|
-
const textParts = [];
|
|
2950
|
-
for (const block of content) {
|
|
2951
|
-
if (block.type === "text" && typeof block.text === "string") {
|
|
2952
|
-
textParts.push(block.text);
|
|
2953
|
-
}
|
|
2954
|
-
}
|
|
2955
|
-
if (textParts.length > 0) {
|
|
2956
|
-
messages.push({ role, text: textParts.join(`
|
|
2957
|
-
`) });
|
|
2958
|
-
}
|
|
2959
|
-
}
|
|
2960
|
-
}
|
|
2961
|
-
return messages;
|
|
2962
|
-
}
|
|
2963
|
-
function resolveHistoryPath(historyPath) {
|
|
2964
|
-
if (historyPath)
|
|
2965
|
-
return historyPath;
|
|
2966
|
-
const override = process.env["ENGRM_CLAUDE_HISTORY_PATH"];
|
|
2967
|
-
if (override)
|
|
2968
|
-
return override;
|
|
2969
|
-
return join3(homedir2(), ".claude", "history.jsonl");
|
|
2970
|
-
}
|
|
2971
|
-
function readHistoryFallback(sessionId, cwd, opts) {
|
|
2972
|
-
const path = resolveHistoryPath(opts?.historyPath);
|
|
2973
|
-
if (!existsSync3(path))
|
|
2974
|
-
return [];
|
|
2975
|
-
let raw;
|
|
2976
|
-
try {
|
|
2977
|
-
raw = readFileSync3(path, "utf-8");
|
|
2978
|
-
} catch {
|
|
2979
|
-
return [];
|
|
2980
|
-
}
|
|
2981
|
-
const targetCanonical = detectProject(cwd).canonical_id;
|
|
2982
|
-
const windowStart = Math.max(0, (opts?.startedAtEpoch ?? Math.floor(Date.now() / 1000) - 6 * 3600) - 600);
|
|
2983
|
-
const windowEnd = (opts?.completedAtEpoch ?? Math.floor(Date.now() / 1000)) + 600;
|
|
2984
|
-
const entries = [];
|
|
2985
|
-
for (const line of raw.split(`
|
|
2986
|
-
`)) {
|
|
2987
|
-
if (!line.trim())
|
|
2988
|
-
continue;
|
|
2989
|
-
let entry;
|
|
2990
|
-
try {
|
|
2991
|
-
entry = JSON.parse(line);
|
|
2992
|
-
} catch {
|
|
2993
|
-
continue;
|
|
2994
|
-
}
|
|
2995
|
-
if (typeof entry?.display !== "string" || typeof entry?.timestamp !== "number")
|
|
2996
|
-
continue;
|
|
2997
|
-
const createdAtEpoch = Math.floor(entry.timestamp / 1000);
|
|
2998
|
-
entries.push({
|
|
2999
|
-
display: entry.display.trim(),
|
|
3000
|
-
project: typeof entry.project === "string" ? entry.project : "",
|
|
3001
|
-
sessionId: typeof entry.sessionId === "string" ? entry.sessionId : "",
|
|
3002
|
-
timestamp: createdAtEpoch
|
|
3003
|
-
});
|
|
3004
|
-
}
|
|
3005
|
-
const bySession = entries.filter((entry) => entry.display.length > 0 && entry.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
|
|
3006
|
-
if (bySession.length > 0) {
|
|
3007
|
-
return dedupeHistoryMessages(bySession.map((entry) => ({
|
|
3008
|
-
role: "user",
|
|
3009
|
-
text: entry.display,
|
|
3010
|
-
createdAtEpoch: entry.timestamp
|
|
3011
|
-
})));
|
|
3012
|
-
}
|
|
3013
|
-
const byProjectAndWindow = entries.filter((entry) => {
|
|
3014
|
-
if (entry.display.length === 0)
|
|
3015
|
-
return false;
|
|
3016
|
-
if (entry.timestamp < windowStart || entry.timestamp > windowEnd)
|
|
3017
|
-
return false;
|
|
3018
|
-
if (!entry.project)
|
|
3019
|
-
return false;
|
|
3020
|
-
return detectProject(entry.project).canonical_id === targetCanonical;
|
|
3021
|
-
}).sort((a, b) => a.timestamp - b.timestamp);
|
|
3022
|
-
return dedupeHistoryMessages(byProjectAndWindow.map((entry) => ({
|
|
3023
|
-
role: "user",
|
|
3024
|
-
text: entry.display,
|
|
3025
|
-
createdAtEpoch: entry.timestamp
|
|
3026
|
-
})));
|
|
3027
|
-
}
|
|
3028
|
-
async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
|
|
3029
|
-
const session = db.getSessionById(sessionId);
|
|
3030
|
-
const transcriptMessages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
|
|
3031
|
-
...message,
|
|
3032
|
-
text: message.text.trim()
|
|
3033
|
-
})).filter((message) => message.text.length > 0);
|
|
3034
|
-
const messages = transcriptMessages.length > 0 ? transcriptMessages.map((message, index) => ({
|
|
3035
|
-
...message,
|
|
3036
|
-
sourceKind: "transcript",
|
|
3037
|
-
transcriptIndex: index + 1,
|
|
3038
|
-
createdAtEpoch: null,
|
|
3039
|
-
remoteSourceId: null
|
|
3040
|
-
})) : readHistoryFallback(sessionId, cwd, {
|
|
3041
|
-
startedAtEpoch: session?.started_at_epoch ?? null,
|
|
3042
|
-
completedAtEpoch: session?.completed_at_epoch ?? null
|
|
3043
|
-
}).map((message) => ({
|
|
3044
|
-
role: message.role,
|
|
3045
|
-
text: message.text,
|
|
3046
|
-
sourceKind: "hook",
|
|
3047
|
-
transcriptIndex: null,
|
|
3048
|
-
createdAtEpoch: message.createdAtEpoch,
|
|
3049
|
-
remoteSourceId: buildHistorySourceId(sessionId, message.createdAtEpoch, message.text)
|
|
3050
|
-
}));
|
|
3051
|
-
if (messages.length === 0)
|
|
3052
|
-
return { imported: 0, total: 0 };
|
|
3053
|
-
const projectId = session?.project_id ?? null;
|
|
3054
|
-
const now = Math.floor(Date.now() / 1000);
|
|
3055
|
-
let imported = 0;
|
|
3056
|
-
for (let index = 0;index < messages.length; index++) {
|
|
3057
|
-
const message = messages[index];
|
|
3058
|
-
const transcriptIndex = message.transcriptIndex ?? index + 1;
|
|
3059
|
-
if (message.sourceKind === "transcript" && db.getTranscriptChatMessage(sessionId, transcriptIndex)) {
|
|
3060
|
-
continue;
|
|
3061
|
-
}
|
|
3062
|
-
if (message.remoteSourceId && db.getChatMessageByRemoteSourceId(message.remoteSourceId)) {
|
|
3063
|
-
continue;
|
|
3064
|
-
}
|
|
3065
|
-
const createdAtEpoch = message.createdAtEpoch ?? Math.max(0, now - (messages.length - transcriptIndex));
|
|
3066
|
-
const row = db.insertChatMessage({
|
|
3067
|
-
session_id: sessionId,
|
|
3068
|
-
project_id: projectId,
|
|
3069
|
-
role: message.role,
|
|
3070
|
-
content: message.text,
|
|
3071
|
-
user_id: config.user_id,
|
|
3072
|
-
device_id: config.device_id,
|
|
3073
|
-
agent: "claude-code",
|
|
3074
|
-
created_at_epoch: createdAtEpoch,
|
|
3075
|
-
remote_source_id: message.remoteSourceId,
|
|
3076
|
-
source_kind: message.sourceKind,
|
|
3077
|
-
transcript_index: message.transcriptIndex
|
|
3078
|
-
});
|
|
3079
|
-
db.addToOutbox("chat_message", row.id);
|
|
3080
|
-
if (message.role === "user") {
|
|
3081
|
-
db.insertUserPrompt({
|
|
3082
|
-
session_id: sessionId,
|
|
3083
|
-
project_id: projectId,
|
|
3084
|
-
prompt: message.text,
|
|
3085
|
-
cwd,
|
|
3086
|
-
user_id: config.user_id,
|
|
3087
|
-
device_id: config.device_id,
|
|
3088
|
-
agent: "claude-code",
|
|
3089
|
-
created_at_epoch: createdAtEpoch
|
|
3090
|
-
});
|
|
3091
|
-
}
|
|
3092
|
-
if (db.vecAvailable) {
|
|
3093
|
-
const embedding = await embedText(composeChatEmbeddingText(message.text));
|
|
3094
|
-
if (embedding) {
|
|
3095
|
-
db.vecChatInsert(row.id, embedding);
|
|
3096
|
-
}
|
|
3097
|
-
}
|
|
3098
|
-
imported++;
|
|
3099
|
-
}
|
|
3100
|
-
return { imported, total: messages.length };
|
|
3101
|
-
}
|
|
3102
|
-
function dedupeHistoryMessages(messages) {
|
|
3103
|
-
const deduped = [];
|
|
3104
|
-
for (const message of messages) {
|
|
3105
|
-
const compact = message.text.replace(/\s+/g, " ").trim();
|
|
3106
|
-
if (!compact)
|
|
3107
|
-
continue;
|
|
3108
|
-
const previous = deduped[deduped.length - 1];
|
|
3109
|
-
if (previous && previous.text.replace(/\s+/g, " ").trim() === compact)
|
|
3110
|
-
continue;
|
|
3111
|
-
deduped.push({ ...message, text: compact });
|
|
3112
|
-
}
|
|
3113
|
-
return deduped;
|
|
3114
|
-
}
|
|
3115
|
-
function buildHistorySourceId(sessionId, createdAtEpoch, text) {
|
|
3116
|
-
const digest = createHash3("sha1").update(text).digest("hex").slice(0, 12);
|
|
3117
|
-
return `history:${sessionId}:${createdAtEpoch}:${digest}`;
|
|
3118
|
-
}
|
|
3119
|
-
function truncateTranscript(messages, maxBytes = 50000) {
|
|
3120
|
-
const lines = [];
|
|
3121
|
-
for (const msg of messages) {
|
|
3122
|
-
lines.push(`[${msg.role}]: ${msg.text}`);
|
|
3123
|
-
}
|
|
3124
|
-
const full = lines.join(`
|
|
3125
|
-
`);
|
|
3126
|
-
if (Buffer.byteLength(full, "utf-8") <= maxBytes)
|
|
3127
|
-
return full;
|
|
3128
|
-
let result = "";
|
|
3129
|
-
for (let i = lines.length - 1;i >= 0; i--) {
|
|
3130
|
-
const candidate = lines[i] + `
|
|
3131
|
-
` + result;
|
|
3132
|
-
if (Buffer.byteLength(candidate, "utf-8") > maxBytes)
|
|
3133
|
-
break;
|
|
3134
|
-
result = candidate;
|
|
3135
|
-
}
|
|
3136
|
-
return result.trim();
|
|
3137
|
-
}
|
|
3138
|
-
async function analyzeTranscript(config, transcript, sessionId) {
|
|
3139
|
-
if (!config.candengo_url || !config.candengo_api_key)
|
|
3140
|
-
return null;
|
|
3141
|
-
const url = `${config.candengo_url}/v1/mem/transcript-analysis`;
|
|
3142
|
-
const controller = new AbortController;
|
|
3143
|
-
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
3144
|
-
try {
|
|
3145
|
-
const response = await fetch(url, {
|
|
3146
|
-
method: "POST",
|
|
3147
|
-
headers: {
|
|
3148
|
-
"Content-Type": "application/json",
|
|
3149
|
-
Authorization: `Bearer ${config.candengo_api_key}`
|
|
3150
|
-
},
|
|
3151
|
-
body: JSON.stringify({
|
|
3152
|
-
transcript,
|
|
3153
|
-
session_id: sessionId
|
|
3154
|
-
}),
|
|
3155
|
-
signal: controller.signal
|
|
3156
|
-
});
|
|
3157
|
-
if (!response.ok)
|
|
3158
|
-
return null;
|
|
3159
|
-
const data = await response.json();
|
|
3160
|
-
if (!Array.isArray(data.plans) || !Array.isArray(data.decisions) || !Array.isArray(data.insights)) {
|
|
3161
|
-
return null;
|
|
3162
|
-
}
|
|
3163
|
-
return data;
|
|
3164
|
-
} catch {
|
|
3165
|
-
return null;
|
|
3166
|
-
} finally {
|
|
3167
|
-
clearTimeout(timeout);
|
|
3168
|
-
}
|
|
3169
|
-
}
|
|
3170
|
-
async function saveTranscriptResults(db, config, results, sessionId, cwd) {
|
|
3171
|
-
let saved = 0;
|
|
3172
|
-
const items = [
|
|
3173
|
-
...results.plans.map((item) => ({ item, type: "decision" })),
|
|
3174
|
-
...results.decisions.map((item) => ({ item, type: "decision" })),
|
|
3175
|
-
...results.insights.map((item) => ({ item, type: "discovery" }))
|
|
3176
|
-
];
|
|
3177
|
-
for (const { item, type } of items) {
|
|
3178
|
-
if (!item.title || item.title.trim().length === 0)
|
|
3179
|
-
continue;
|
|
3180
|
-
const result = await saveObservation(db, config, {
|
|
3181
|
-
type,
|
|
3182
|
-
title: item.title.slice(0, 80),
|
|
3183
|
-
narrative: item.narrative,
|
|
3184
|
-
concepts: item.concepts,
|
|
3185
|
-
session_id: sessionId,
|
|
3186
|
-
cwd
|
|
3187
|
-
});
|
|
3188
|
-
if (result.success)
|
|
3189
|
-
saved++;
|
|
3190
|
-
}
|
|
3191
|
-
return saved;
|
|
3192
|
-
}
|
|
3193
|
-
|
|
3194
|
-
// src/tools/recent-chat.ts
|
|
3195
|
-
function summarizeChatSources(messages) {
|
|
3196
|
-
return messages.reduce((summary, message) => {
|
|
3197
|
-
summary[getChatCaptureOrigin(message)] += 1;
|
|
3198
|
-
return summary;
|
|
3199
|
-
}, { transcript: 0, history: 0, hook: 0 });
|
|
3200
|
-
}
|
|
3201
|
-
function getChatCoverageState(messagesOrSummary) {
|
|
3202
|
-
const summary = Array.isArray(messagesOrSummary) ? summarizeChatSources(messagesOrSummary) : messagesOrSummary;
|
|
3203
|
-
if (summary.transcript > 0)
|
|
3204
|
-
return "transcript-backed";
|
|
3205
|
-
if (summary.history > 0)
|
|
3206
|
-
return "history-backed";
|
|
3207
|
-
if (summary.hook > 0)
|
|
3208
|
-
return "hook-only";
|
|
3209
|
-
return "none";
|
|
3210
|
-
}
|
|
3211
|
-
function getChatCaptureOrigin(message) {
|
|
3212
|
-
if (message.source_kind === "transcript")
|
|
3213
|
-
return "transcript";
|
|
3214
|
-
if (typeof message.remote_source_id === "string" && message.remote_source_id.startsWith("history:")) {
|
|
3215
|
-
return "history";
|
|
3216
|
-
}
|
|
3217
|
-
return "hook";
|
|
3218
|
-
}
|
|
3219
|
-
|
|
3220
|
-
// src/tools/session-story.ts
|
|
3221
|
-
function getSessionStory(db, input) {
|
|
3222
|
-
const session = db.getSessionById(input.session_id);
|
|
3223
|
-
const summary = db.getSessionSummary(input.session_id);
|
|
3224
|
-
const prompts = db.getSessionUserPrompts(input.session_id, 50);
|
|
3225
|
-
const chatMessages = db.getSessionChatMessages(input.session_id, 50);
|
|
3226
|
-
const toolEvents = db.getSessionToolEvents(input.session_id, 100);
|
|
3227
|
-
const allObservations = db.getObservationsBySession(input.session_id);
|
|
3228
|
-
const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
|
|
3229
|
-
const rollingHandoffDrafts = handoffs.filter((obs) => isDraftHandoff(obs));
|
|
3230
|
-
const savedHandoffs = handoffs.filter((obs) => !isDraftHandoff(obs));
|
|
3231
|
-
const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
|
|
3232
|
-
const metrics = db.getSessionMetrics(input.session_id);
|
|
3233
|
-
const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
|
|
3234
|
-
const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
|
|
3235
|
-
return {
|
|
3236
|
-
session,
|
|
3237
|
-
project_name: projectName,
|
|
3238
|
-
summary,
|
|
3239
|
-
prompts,
|
|
3240
|
-
chat_messages: chatMessages,
|
|
3241
|
-
chat_source_summary: summarizeChatSources(chatMessages),
|
|
3242
|
-
chat_coverage_state: getChatCoverageState(chatMessages),
|
|
3243
|
-
tool_events: toolEvents,
|
|
3244
|
-
observations,
|
|
3245
|
-
handoffs,
|
|
3246
|
-
saved_handoffs: savedHandoffs,
|
|
3247
|
-
rolling_handoff_drafts: rollingHandoffDrafts,
|
|
3248
|
-
metrics,
|
|
3249
|
-
capture_state: classifyCaptureState({
|
|
3250
|
-
hasSummary: Boolean(summary?.request || summary?.completed),
|
|
3251
|
-
promptCount: prompts.length,
|
|
3252
|
-
toolEventCount: toolEvents.length
|
|
3253
|
-
}),
|
|
3254
|
-
capture_gaps: buildCaptureGaps({
|
|
3255
|
-
promptCount: prompts.length,
|
|
3256
|
-
toolEventCount: toolEvents.length,
|
|
3257
|
-
toolCallsCount: metrics?.tool_calls_count ?? 0,
|
|
3258
|
-
observationCount: observations.length,
|
|
3259
|
-
hasSummary: Boolean(summary?.request || summary?.completed)
|
|
3260
|
-
}),
|
|
3261
|
-
latest_request: latestRequest,
|
|
3262
|
-
recent_outcomes: collectRecentOutcomes(observations),
|
|
3263
|
-
hot_files: collectHotFiles(observations),
|
|
3264
|
-
provenance_summary: collectProvenanceSummary(observations)
|
|
3265
|
-
};
|
|
3266
|
-
}
|
|
3267
|
-
function classifyCaptureState(input) {
|
|
3268
|
-
if (input.promptCount > 0 && input.toolEventCount > 0)
|
|
3269
|
-
return "rich";
|
|
3270
|
-
if (input.promptCount > 0 || input.toolEventCount > 0)
|
|
3271
|
-
return "partial";
|
|
3272
|
-
if (input.hasSummary)
|
|
3273
|
-
return "summary-only";
|
|
3274
|
-
return "legacy";
|
|
3275
|
-
}
|
|
3276
|
-
function buildCaptureGaps(input) {
|
|
3277
|
-
const gaps = [];
|
|
3278
|
-
if (input.promptCount === 0)
|
|
3279
|
-
gaps.push("missing prompts");
|
|
3280
|
-
if (input.toolCallsCount > 0 && input.toolEventCount === 0) {
|
|
3281
|
-
gaps.push("missing raw tool chronology");
|
|
3282
|
-
} else if (input.toolEventCount === 0) {
|
|
3283
|
-
gaps.push("no tool events");
|
|
3284
|
-
}
|
|
3285
|
-
if (input.observationCount === 0 && input.hasSummary) {
|
|
3286
|
-
gaps.push("summary without reusable observations");
|
|
3287
|
-
}
|
|
3288
|
-
return gaps;
|
|
3289
|
-
}
|
|
3290
|
-
function collectRecentOutcomes(observations) {
|
|
3291
|
-
const seen = new Set;
|
|
3292
|
-
const outcomes = [];
|
|
3293
|
-
for (const obs of observations) {
|
|
3294
|
-
if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
|
|
3295
|
-
continue;
|
|
3296
|
-
const title = obs.title.trim();
|
|
3297
|
-
if (!title || looksLikeFileOperationTitle(title))
|
|
3298
|
-
continue;
|
|
3299
|
-
const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
|
|
3300
|
-
if (seen.has(normalized))
|
|
3301
|
-
continue;
|
|
3302
|
-
seen.add(normalized);
|
|
3303
|
-
outcomes.push(title);
|
|
3304
|
-
if (outcomes.length >= 6)
|
|
3305
|
-
break;
|
|
3306
|
-
}
|
|
3307
|
-
return outcomes;
|
|
3308
|
-
}
|
|
3309
|
-
function collectHotFiles(observations) {
|
|
3310
|
-
const counts = new Map;
|
|
3311
|
-
for (const obs of observations) {
|
|
3312
|
-
for (const path of [...parseJsonArray2(obs.files_modified), ...parseJsonArray2(obs.files_read)]) {
|
|
3313
|
-
counts.set(path, (counts.get(path) ?? 0) + 1);
|
|
3314
|
-
}
|
|
3315
|
-
}
|
|
3316
|
-
return Array.from(counts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 8);
|
|
3317
|
-
}
|
|
3318
|
-
function parseJsonArray2(value) {
|
|
3319
|
-
if (!value)
|
|
3320
|
-
return [];
|
|
3321
|
-
try {
|
|
3322
|
-
const parsed = JSON.parse(value);
|
|
3323
|
-
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
|
|
3324
|
-
} catch {
|
|
3325
|
-
return [];
|
|
3326
|
-
}
|
|
3327
|
-
}
|
|
3328
|
-
function looksLikeFileOperationTitle(value) {
|
|
3329
|
-
return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
|
|
3330
|
-
}
|
|
3331
|
-
function collectProvenanceSummary(observations) {
|
|
3332
|
-
const counts = new Map;
|
|
3333
|
-
for (const obs of observations) {
|
|
3334
|
-
if (!obs.source_tool)
|
|
3335
|
-
continue;
|
|
3336
|
-
counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
|
|
3337
|
-
}
|
|
3338
|
-
return Array.from(counts.entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
|
|
3339
|
-
}
|
|
3340
|
-
|
|
3341
|
-
// src/tools/handoffs.ts
|
|
3342
|
-
async function upsertRollingHandoff(db, config, input) {
|
|
3343
|
-
const resolved = resolveTargetSession(db, input.cwd, config.user_id, input.session_id);
|
|
3344
|
-
if (!resolved.session) {
|
|
3345
|
-
return {
|
|
3346
|
-
success: false,
|
|
3347
|
-
reason: "No recent session found to draft a handoff yet"
|
|
3348
|
-
};
|
|
3349
|
-
}
|
|
3350
|
-
const story = getSessionStory(db, { session_id: resolved.session.session_id });
|
|
3351
|
-
if (!story.session) {
|
|
3352
|
-
return {
|
|
3353
|
-
success: false,
|
|
3354
|
-
reason: `Session ${resolved.session.session_id} not found`
|
|
3355
|
-
};
|
|
3356
|
-
}
|
|
3357
|
-
const includeChat = input.include_chat === true || input.include_chat !== false && shouldAutoIncludeChat(story);
|
|
3358
|
-
const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 3, 6));
|
|
3359
|
-
const title = `Handoff Draft: ${buildHandoffTitle(story.summary, story.latest_request)}`;
|
|
3360
|
-
const narrative = buildHandoffNarrative(story.summary, story, {
|
|
3361
|
-
includeChat,
|
|
3362
|
-
chatLimit
|
|
3363
|
-
});
|
|
3364
|
-
const facts = buildHandoffFacts(story.summary, story);
|
|
3365
|
-
const concepts = buildDraftHandoffConcepts(story.project_name, story.capture_state);
|
|
3366
|
-
const existing = getSessionRollingHandoff(db, story.session.session_id);
|
|
3367
|
-
const now = Math.floor(Date.now() / 1000);
|
|
3368
|
-
if (existing) {
|
|
3369
|
-
const nextFacts = JSON.stringify(facts);
|
|
3370
|
-
const nextConcepts = JSON.stringify(concepts);
|
|
3371
|
-
const shouldRefresh = existing.title !== title || (existing.narrative ?? null) !== narrative || (existing.facts ?? null) !== nextFacts || (existing.concepts ?? null) !== nextConcepts || now - existing.created_at_epoch >= 120;
|
|
3372
|
-
if (!shouldRefresh) {
|
|
3373
|
-
return {
|
|
3374
|
-
success: true,
|
|
3375
|
-
observation_id: existing.id,
|
|
3376
|
-
session_id: story.session.session_id,
|
|
3377
|
-
title: existing.title
|
|
3378
|
-
};
|
|
3379
|
-
}
|
|
3380
|
-
const updated = db.updateObservationContent(existing.id, {
|
|
3381
|
-
title,
|
|
3382
|
-
narrative,
|
|
3383
|
-
facts: nextFacts,
|
|
3384
|
-
concepts: nextConcepts,
|
|
3385
|
-
created_at_epoch: now
|
|
3386
|
-
});
|
|
3387
|
-
if (!updated) {
|
|
3388
|
-
return {
|
|
3389
|
-
success: false,
|
|
3390
|
-
reason: "Failed to update rolling handoff draft"
|
|
3391
|
-
};
|
|
3392
|
-
}
|
|
3393
|
-
db.addToOutbox("observation", updated.id);
|
|
3394
|
-
return {
|
|
3395
|
-
success: true,
|
|
3396
|
-
observation_id: updated.id,
|
|
3397
|
-
session_id: story.session.session_id,
|
|
3398
|
-
title: updated.title
|
|
3399
|
-
};
|
|
3400
|
-
}
|
|
3401
|
-
const result = await saveObservation(db, config, {
|
|
3402
|
-
type: "message",
|
|
3403
|
-
title,
|
|
3404
|
-
narrative,
|
|
3405
|
-
facts,
|
|
3406
|
-
concepts,
|
|
3407
|
-
session_id: story.session.session_id,
|
|
3408
|
-
cwd: input.cwd,
|
|
3409
|
-
agent: "engrm-handoff",
|
|
3410
|
-
source_tool: "rolling_handoff"
|
|
3411
|
-
});
|
|
3412
|
-
return {
|
|
3413
|
-
success: result.success,
|
|
3414
|
-
observation_id: result.observation_id,
|
|
3415
|
-
session_id: story.session.session_id,
|
|
3416
|
-
title,
|
|
3417
|
-
reason: result.reason
|
|
3418
|
-
};
|
|
3419
|
-
}
|
|
3420
|
-
function getRecentHandoffs(db, input) {
|
|
3421
|
-
const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
|
|
3422
|
-
const queryLimit = input.current_device_id ? Math.max(limit, Math.min(limit * 5, 50)) : limit;
|
|
3423
|
-
const projectScoped = input.project_scoped !== false;
|
|
3424
|
-
let projectId = null;
|
|
3425
|
-
let projectName;
|
|
3426
|
-
if (projectScoped) {
|
|
3427
|
-
const cwd = input.cwd ?? process.cwd();
|
|
3428
|
-
const detected = detectProject(cwd);
|
|
3429
|
-
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
3430
|
-
if (project) {
|
|
3431
|
-
projectId = project.id;
|
|
3432
|
-
projectName = project.name;
|
|
3433
|
-
}
|
|
3434
|
-
}
|
|
3435
|
-
const conditions = [
|
|
3436
|
-
"o.type = 'message'",
|
|
3437
|
-
"o.lifecycle IN ('active', 'aging', 'pinned')",
|
|
3438
|
-
"o.superseded_by IS NULL",
|
|
3439
|
-
`(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
|
|
3440
|
-
];
|
|
3441
|
-
const params = [];
|
|
3442
|
-
if (input.user_id) {
|
|
3443
|
-
conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
|
|
3444
|
-
params.push(input.user_id);
|
|
3445
|
-
}
|
|
3446
|
-
if (projectId !== null) {
|
|
3447
|
-
conditions.push("o.project_id = ?");
|
|
3448
|
-
params.push(projectId);
|
|
3449
|
-
}
|
|
3450
|
-
params.push(queryLimit);
|
|
3451
|
-
const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
|
|
3452
|
-
FROM observations o
|
|
3453
|
-
LEFT JOIN projects p ON p.id = o.project_id
|
|
3454
|
-
WHERE ${conditions.join(" AND ")}
|
|
3455
|
-
ORDER BY o.created_at_epoch DESC, o.id DESC
|
|
3456
|
-
LIMIT ?`).all(...params);
|
|
3457
|
-
handoffs.sort((a, b) => compareHandoffs(a, b, input.current_device_id));
|
|
3458
|
-
return {
|
|
3459
|
-
handoffs: handoffs.slice(0, limit),
|
|
3460
|
-
project: projectName
|
|
3461
|
-
};
|
|
3462
|
-
}
|
|
3463
|
-
function formatHandoffSource(handoff) {
|
|
3464
|
-
const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
|
|
3465
|
-
const ageLabel = ageSeconds < 3600 ? `${Math.max(1, Math.floor(ageSeconds / 60) || 1)}m ago` : ageSeconds < 86400 ? `${Math.floor(ageSeconds / 3600)}h ago` : `${Math.floor(ageSeconds / 86400)}d ago`;
|
|
3466
|
-
return `from ${handoff.device_id} · ${ageLabel}`;
|
|
3467
|
-
}
|
|
3468
|
-
function isDraftHandoff(obs) {
|
|
3469
|
-
if (obs.title.startsWith("Handoff Draft:"))
|
|
3470
|
-
return true;
|
|
3471
|
-
const concepts = parseJsonArray3(obs.concepts);
|
|
3472
|
-
return concepts.includes("draft-handoff") || concepts.includes("auto-handoff");
|
|
3473
|
-
}
|
|
3474
|
-
function getSessionRollingHandoff(db, sessionId) {
|
|
3475
|
-
return db.db.query(`SELECT o.*, p.name AS project_name
|
|
3476
|
-
FROM observations o
|
|
3477
|
-
LEFT JOIN projects p ON p.id = o.project_id
|
|
3478
|
-
WHERE o.session_id = ?
|
|
3479
|
-
AND o.type = 'message'
|
|
3480
|
-
AND o.lifecycle IN ('active', 'aging', 'pinned')
|
|
3481
|
-
AND o.superseded_by IS NULL
|
|
3482
|
-
AND (o.title LIKE 'Handoff Draft:%' OR o.concepts LIKE '%"draft-handoff"%')
|
|
3483
|
-
ORDER BY o.created_at_epoch DESC, o.id DESC
|
|
3484
|
-
LIMIT 1`).get(sessionId) ?? null;
|
|
3485
|
-
}
|
|
3486
|
-
function compareHandoffs(a, b, currentDeviceId) {
|
|
3487
|
-
const aDraft = isDraftHandoff(a) ? 1 : 0;
|
|
3488
|
-
const bDraft = isDraftHandoff(b) ? 1 : 0;
|
|
3489
|
-
if (aDraft !== bDraft)
|
|
3490
|
-
return aDraft - bDraft;
|
|
3491
|
-
if (currentDeviceId) {
|
|
3492
|
-
const aOther = a.device_id !== currentDeviceId ? 1 : 0;
|
|
3493
|
-
const bOther = b.device_id !== currentDeviceId ? 1 : 0;
|
|
3494
|
-
if (aOther !== bOther)
|
|
3495
|
-
return bOther - aOther;
|
|
3496
|
-
}
|
|
3497
|
-
if (b.created_at_epoch !== a.created_at_epoch) {
|
|
3498
|
-
return b.created_at_epoch - a.created_at_epoch;
|
|
3499
|
-
}
|
|
3500
|
-
return b.id - a.id;
|
|
3501
|
-
}
|
|
3502
|
-
function resolveTargetSession(db, cwd, userId, sessionId) {
|
|
3503
|
-
if (sessionId) {
|
|
3504
|
-
const session = db.getSessionById(sessionId);
|
|
3505
|
-
if (!session)
|
|
3506
|
-
return { session: null };
|
|
3507
|
-
const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
|
|
3508
|
-
return {
|
|
3509
|
-
session: {
|
|
3510
|
-
...session,
|
|
3511
|
-
project_name: projectName ?? null,
|
|
3512
|
-
request: db.getSessionSummary(sessionId)?.request ?? null,
|
|
3513
|
-
completed: db.getSessionSummary(sessionId)?.completed ?? null,
|
|
3514
|
-
current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
|
|
3515
|
-
capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
|
|
3516
|
-
recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
|
|
3517
|
-
hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
|
|
3518
|
-
recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
|
|
3519
|
-
prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
|
|
3520
|
-
tool_event_count: db.getSessionToolEvents(sessionId, 200).length
|
|
3521
|
-
},
|
|
3522
|
-
projectName: projectName ?? undefined
|
|
3523
|
-
};
|
|
3524
|
-
}
|
|
3525
|
-
const detected = detectProject(cwd ?? process.cwd());
|
|
3526
|
-
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
3527
|
-
const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
|
|
3528
|
-
return {
|
|
3529
|
-
session: sessions[0] ?? null,
|
|
3530
|
-
projectName: project?.name
|
|
3531
|
-
};
|
|
3532
|
-
}
|
|
3533
|
-
function buildHandoffTitle(summary, latestRequest, explicit) {
|
|
3534
|
-
const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
|
|
3535
|
-
return compactLine2(chosen) ?? "Current work";
|
|
3536
|
-
}
|
|
3537
|
-
function buildHandoffNarrative(summary, story, options) {
|
|
3538
|
-
const sections = [];
|
|
3539
|
-
if (summary?.request || story.latest_request) {
|
|
3540
|
-
sections.push(`Request: ${summary?.request ?? story.latest_request}`);
|
|
3541
|
-
}
|
|
3542
|
-
if (summary?.current_thread) {
|
|
3543
|
-
sections.push(`Current thread: ${summary.current_thread}`);
|
|
3544
|
-
}
|
|
3545
|
-
if (summary?.investigated) {
|
|
3546
|
-
sections.push(`Investigated: ${summary.investigated}`);
|
|
3547
|
-
}
|
|
3548
|
-
if (summary?.learned) {
|
|
3549
|
-
sections.push(`Learned: ${summary.learned}`);
|
|
3550
|
-
}
|
|
3551
|
-
if (summary?.completed) {
|
|
3552
|
-
sections.push(`Completed: ${summary.completed}`);
|
|
3553
|
-
}
|
|
3554
|
-
if (summary?.next_steps) {
|
|
3555
|
-
sections.push(`Next Steps: ${summary.next_steps}`);
|
|
3556
|
-
}
|
|
3557
|
-
if (story.recent_outcomes.length > 0) {
|
|
3558
|
-
sections.push(`Recent outcomes:
|
|
3559
|
-
${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
|
|
3560
|
-
`)}`);
|
|
3561
|
-
}
|
|
3562
|
-
if (story.hot_files.length > 0) {
|
|
3563
|
-
sections.push(`Hot files:
|
|
3564
|
-
${story.hot_files.slice(0, 5).map((file) => `- ${file.path}`).join(`
|
|
3565
|
-
`)}`);
|
|
3566
|
-
}
|
|
3567
|
-
if (story.provenance_summary.length > 0) {
|
|
3568
|
-
sections.push(`Tool trail:
|
|
3569
|
-
${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
|
|
3570
|
-
`)}`);
|
|
3571
|
-
}
|
|
3572
|
-
if (options.includeChat && story.chat_messages.length > 0) {
|
|
3573
|
-
const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine2(msg.content) ?? msg.content.slice(0, 120)}`);
|
|
3574
|
-
sections.push(`Chat snippets:
|
|
3575
|
-
${chatLines.join(`
|
|
3576
|
-
`)}`);
|
|
3577
|
-
}
|
|
3578
|
-
return sections.filter(Boolean).join(`
|
|
3579
|
-
|
|
3580
|
-
`);
|
|
3581
|
-
}
|
|
3582
|
-
function shouldAutoIncludeChat(story) {
|
|
3583
|
-
if (story.chat_messages.length === 0)
|
|
3584
|
-
return false;
|
|
3585
|
-
const summary = story.summary;
|
|
3586
|
-
const thinSummary = !summary?.completed && !summary?.current_thread && story.recent_outcomes.length < 2;
|
|
3587
|
-
const thinChronology = story.capture_state !== "rich" || story.tool_events.length === 0;
|
|
3588
|
-
return thinSummary || thinChronology;
|
|
3589
|
-
}
|
|
3590
|
-
function buildHandoffFacts(summary, story) {
|
|
3591
|
-
const facts = [
|
|
3592
|
-
`session_id=${story.session?.session_id ?? "unknown"}`,
|
|
3593
|
-
`capture_state=${story.capture_state}`,
|
|
3594
|
-
story.project_name ? `project=${story.project_name}` : null,
|
|
3595
|
-
summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
|
|
3596
|
-
story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
|
|
3597
|
-
story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
|
|
3598
|
-
];
|
|
3599
|
-
return facts.filter((item) => Boolean(item));
|
|
3600
|
-
}
|
|
3601
|
-
function buildDraftHandoffConcepts(projectName, captureState) {
|
|
3602
|
-
return [
|
|
3603
|
-
"handoff",
|
|
3604
|
-
"draft-handoff",
|
|
3605
|
-
"auto-handoff",
|
|
3606
|
-
`capture:${captureState}`,
|
|
3607
|
-
...projectName ? [projectName] : []
|
|
3608
|
-
];
|
|
3609
|
-
}
|
|
3610
|
-
function looksLikeHandoff(obs) {
|
|
3611
|
-
if (obs.title.startsWith("Handoff:") || obs.title.startsWith("Handoff Draft:"))
|
|
3612
|
-
return true;
|
|
3613
|
-
const concepts = parseJsonArray3(obs.concepts);
|
|
3614
|
-
return concepts.includes("handoff") || concepts.includes("session-handoff") || concepts.includes("draft-handoff");
|
|
3615
|
-
}
|
|
3616
|
-
function parseJsonArray3(value) {
|
|
3617
|
-
if (!value)
|
|
3618
|
-
return [];
|
|
3619
|
-
try {
|
|
3620
|
-
const parsed = JSON.parse(value);
|
|
3621
|
-
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
|
|
3622
|
-
} catch {
|
|
3623
|
-
return [];
|
|
3624
|
-
}
|
|
3625
|
-
}
|
|
3626
|
-
function compactLine2(value) {
|
|
3627
|
-
if (value === null || value === undefined)
|
|
3628
|
-
return null;
|
|
3629
|
-
let text;
|
|
3630
|
-
if (typeof value === "string") {
|
|
3631
|
-
text = value;
|
|
3632
|
-
} else {
|
|
3633
|
-
try {
|
|
3634
|
-
text = JSON.stringify(value);
|
|
3635
|
-
} catch {
|
|
3636
|
-
text = String(value);
|
|
3637
|
-
}
|
|
3638
|
-
}
|
|
3639
|
-
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
3640
|
-
if (!trimmed)
|
|
3641
|
-
return null;
|
|
3642
|
-
return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
|
|
3643
|
-
}
|
|
3644
|
-
|
|
3645
2216
|
// hooks/user-prompt-submit.ts
|
|
3646
2217
|
async function main() {
|
|
3647
2218
|
const event = await parseStdinJson();
|
|
@@ -3669,7 +2240,6 @@ async function main() {
|
|
|
3669
2240
|
device_id: config.device_id,
|
|
3670
2241
|
agent: "claude-code"
|
|
3671
2242
|
});
|
|
3672
|
-
await syncTranscriptChat(db, config, event.session_id, event.cwd);
|
|
3673
2243
|
const chatMessage = db.insertChatMessage({
|
|
3674
2244
|
session_id: event.session_id,
|
|
3675
2245
|
project_id: project.id,
|
|
@@ -3681,12 +2251,6 @@ async function main() {
|
|
|
3681
2251
|
source_kind: "hook"
|
|
3682
2252
|
});
|
|
3683
2253
|
db.addToOutbox("chat_message", chatMessage.id);
|
|
3684
|
-
if (db.vecAvailable) {
|
|
3685
|
-
const chatEmbedding = await embedText(composeChatEmbeddingText(event.prompt));
|
|
3686
|
-
if (chatEmbedding) {
|
|
3687
|
-
db.vecChatInsert(chatMessage.id, chatEmbedding);
|
|
3688
|
-
}
|
|
3689
|
-
}
|
|
3690
2254
|
const compactPrompt = event.prompt.replace(/\s+/g, " ").trim();
|
|
3691
2255
|
if (compactPrompt.length >= 8) {
|
|
3692
2256
|
const sessionPrompts = db.getSessionUserPrompts(event.session_id, 20);
|
|
@@ -3709,10 +2273,6 @@ async function main() {
|
|
|
3709
2273
|
recent_outcomes: JSON.stringify(handoff.recent_outcomes)
|
|
3710
2274
|
});
|
|
3711
2275
|
db.addToOutbox("summary", summary.id);
|
|
3712
|
-
await upsertRollingHandoff(db, config, {
|
|
3713
|
-
session_id: event.session_id,
|
|
3714
|
-
cwd: event.cwd
|
|
3715
|
-
});
|
|
3716
2276
|
}
|
|
3717
2277
|
} finally {
|
|
3718
2278
|
db.close();
|