@merittdev/horus 0.7.0 → 0.8.0

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.
Files changed (2) hide show
  1. package/dist/index.cjs +232 -33
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -65880,7 +65880,7 @@ init_cjs_shims();
65880
65880
 
65881
65881
  // ../../packages/core/src/version.ts
65882
65882
  init_cjs_shims();
65883
- var HORUS_VERSION = true ? "0.7.0" : "dev";
65883
+ var HORUS_VERSION = true ? "0.8.0" : "dev";
65884
65884
  var PINNED_SOURCE_VERSION = "1.0.7";
65885
65885
 
65886
65886
  // ../../packages/core/src/evidence.ts
@@ -71139,7 +71139,9 @@ var SourceCodeProvider = class {
71139
71139
  const exactSymbols = exactRows.map((row) => ({
71140
71140
  id: String(row[0] ?? ""),
71141
71141
  name: String(row[1] ?? ""),
71142
- filePath: String(row[2] ?? "")
71142
+ filePath: String(row[2] ?? ""),
71143
+ score: 1
71144
+ // an exact-name Cypher hit is authoritative
71143
71145
  }));
71144
71146
  const exactIds = new Set(exactSymbols.map((s5) => s5.id));
71145
71147
  const ql = query.toLowerCase();
@@ -71154,7 +71156,7 @@ var SourceCodeProvider = class {
71154
71156
  semantic.sort((a2, b5) => b5.rank - a2.rank || b5.r.score - a2.r.score);
71155
71157
  const combined = [
71156
71158
  ...exactSymbols,
71157
- ...semantic.map(({ r }) => ({ id: r.nodeId, name: r.name, filePath: r.filePath }))
71159
+ ...semantic.map(({ r }) => ({ id: r.nodeId, name: r.name, filePath: r.filePath, score: r.score }))
71158
71160
  ];
71159
71161
  return this.hydrateLines(combined.slice(0, limit));
71160
71162
  }
@@ -71210,6 +71212,7 @@ var SourceCodeProvider = class {
71210
71212
  const isDead = Boolean(node?.[8]);
71211
71213
  const content = node?.[9];
71212
71214
  const snippet = typeof content === "string" ? content.slice(0, 600) : void 0;
71215
+ const sourceBody = typeof content === "string" ? content.slice(0, 2e4) : void 0;
71213
71216
  const callees = calleeRows.map((row) => this.cypherRowToSymbol(row, 0, 1, 2, 3, 4));
71214
71217
  const callers = callerRows.map((row) => this.cypherRowToSymbol(row, 0, 1, 2, 3, 4));
71215
71218
  const usesType = usesTypeRows.map((row) => this.cypherRowToSymbol(row, 0, 1, 2, 3));
@@ -71236,6 +71239,7 @@ var SourceCodeProvider = class {
71236
71239
  return {
71237
71240
  symbol,
71238
71241
  snippet,
71242
+ ...sourceBody !== void 0 ? { sourceBody } : {},
71239
71243
  callers,
71240
71244
  callees,
71241
71245
  imports,
@@ -71780,8 +71784,12 @@ function shortTs(iso) {
71780
71784
  }
71781
71785
  function buildErrorAnalysisBody(q2, field = "event_code", mapping = MERITT_FIELD_MAPPING) {
71782
71786
  const filters = [];
71783
- const minLevel = q2.level !== void 0 && levelToValue(q2.level) > 50 ? q2.level : "error";
71784
- filters.push(buildLevelFilter(mapping, minLevel));
71787
+ if (q2.eventCode === void 0) {
71788
+ const minLevel = q2.level !== void 0 && levelToValue(q2.level) > 50 ? q2.level : "error";
71789
+ filters.push(buildLevelFilter(mapping, minLevel));
71790
+ } else if (q2.level !== void 0 && levelToValue(q2.level) > 50) {
71791
+ filters.push(buildLevelFilter(mapping, q2.level));
71792
+ }
71785
71793
  if (q2.from !== void 0 || q2.to !== void 0) {
71786
71794
  const rangeClause = {};
71787
71795
  if (q2.from !== void 0) rangeClause["gte"] = q2.from;
@@ -89233,18 +89241,58 @@ async function createLocalDb(opts) {
89233
89241
  } catch {
89234
89242
  }
89235
89243
  assertEmbeddedAssetsPresent();
89236
- const client = new Ve3(dataDir);
89237
- await client.waitReady;
89238
- await applyEmbeddedMigrations(client);
89239
- const db = drizzle2(client, { schema: schema_exports });
89240
- return {
89241
- db,
89242
- sql: {
89243
- end: async () => {
89244
- await client.close();
89244
+ const releaseLock = await acquireDbLock(dataDir);
89245
+ try {
89246
+ const client = new Ve3(dataDir);
89247
+ await client.waitReady;
89248
+ await applyEmbeddedMigrations(client);
89249
+ const db = drizzle2(client, { schema: schema_exports });
89250
+ return {
89251
+ db,
89252
+ sql: {
89253
+ end: async () => {
89254
+ await client.close();
89255
+ releaseLock();
89256
+ }
89245
89257
  }
89246
- }
89258
+ };
89259
+ } catch (e) {
89260
+ releaseLock();
89261
+ throw e;
89262
+ }
89263
+ }
89264
+ async function acquireDbLock(dataDir) {
89265
+ const lockPath = `${dataDir}.lock`;
89266
+ const STALE_MS = 6e4;
89267
+ const TIMEOUT_MS = 3e4;
89268
+ const start2 = Date.now();
89269
+ const noop3 = () => {
89247
89270
  };
89271
+ for (; ; ) {
89272
+ try {
89273
+ const fd = (0, import_node_fs4.openSync)(lockPath, "wx");
89274
+ (0, import_node_fs4.writeSync)(fd, `${process.pid} ${Date.now()}`);
89275
+ (0, import_node_fs4.closeSync)(fd);
89276
+ return () => {
89277
+ try {
89278
+ (0, import_node_fs4.unlinkSync)(lockPath);
89279
+ } catch {
89280
+ }
89281
+ };
89282
+ } catch (e) {
89283
+ if (e.code !== "EEXIST") return noop3;
89284
+ try {
89285
+ if (Date.now() - (0, import_node_fs4.statSync)(lockPath).mtimeMs > STALE_MS) {
89286
+ (0, import_node_fs4.unlinkSync)(lockPath);
89287
+ continue;
89288
+ }
89289
+ } catch {
89290
+ continue;
89291
+ }
89292
+ if (Date.now() - start2 > TIMEOUT_MS) return noop3;
89293
+ await new Promise((r) => setTimeout(r, 100));
89294
+ }
89295
+ }
89248
89296
  }
89249
89297
  var EMBEDDED_PGLITE_ASSETS = ["pglite.wasm", "pglite.data", "initdb.wasm"];
89250
89298
  function assertEmbeddedAssetsPresent() {
@@ -89841,6 +89889,34 @@ function renderReport(r) {
89841
89889
  if (r.input.repo) lines.push(`Repo: ${r.input.repo}`);
89842
89890
  if (r.input.service) lines.push(`Service: ${r.input.service}`);
89843
89891
  if (r.input.since) lines.push(`Since: ${r.input.since}`);
89892
+ if (r.behavioral) {
89893
+ lines.push("");
89894
+ lines.push("## Summary");
89895
+ lines.push(r.summary);
89896
+ lines.push("");
89897
+ lines.push(`## Flow${r.behavioral.entry ? ` \u2014 ${r.behavioral.entry.name}` : ""}`);
89898
+ if (r.behavioral.steps.length === 0) {
89899
+ lines.push("(no execution flow could be reconstructed from the source graph)");
89900
+ } else {
89901
+ r.behavioral.steps.slice(0, 20).forEach((s5, i3) => {
89902
+ lines.push(` ${i3 + 1}. ${s5.name} (${formatSymbolLocation(s5.filePath, s5.startLine, s5.endLine)})`);
89903
+ });
89904
+ if (r.behavioral.steps.length > 20) lines.push(` \u2026 and ${r.behavioral.steps.length - 20} more`);
89905
+ }
89906
+ if (r.behavioral.externalCalls.length > 0 || r.behavioral.persistence.length > 0) lines.push("");
89907
+ if (r.behavioral.externalCalls.length > 0) lines.push(`External calls: ${r.behavioral.externalCalls.join(", ")}`);
89908
+ if (r.behavioral.persistence.length > 0) lines.push(`Persistence: ${r.behavioral.persistence.join(", ")}`);
89909
+ lines.push("");
89910
+ if (r.nextActions.length > 0) {
89911
+ lines.push("## Next actions");
89912
+ for (const a2 of r.nextActions) lines.push(`- ${a2}`);
89913
+ lines.push("");
89914
+ }
89915
+ lines.push(
89916
+ '\u2014 Behavioral walkthrough, not a fault hunt. Phrase it as a symptom (e.g. "X failing") for an incident investigation.'
89917
+ );
89918
+ return lines.join("\n");
89919
+ }
89844
89920
  lines.push(`Confidence: ${r.confidence.toFixed(2)}`);
89845
89921
  const caveat = runtimeSourceCaveat(r);
89846
89922
  if (caveat) lines.push(` \u21B3 ${caveat}`);
@@ -91795,19 +91871,21 @@ function seedRole(s5) {
91795
91871
  if (/util|helper/i.test(hay)) return "util";
91796
91872
  return "code";
91797
91873
  }
91798
- function scoreSeed(s5, index2, hintTokens, changedFiles) {
91874
+ function scoreSeed(s5, index2, hintTokens, changedFiles, hintHasCode) {
91799
91875
  const hay = `${s5.name} ${s5.filePath}`.toLowerCase();
91800
91876
  let score = 0;
91801
- if (PREFER.test(hay)) score += 3;
91877
+ if (PREFER.test(hay)) score += hintHasCode ? 0 : 3;
91802
91878
  if (DEMOTE.test(hay)) score -= 3;
91803
91879
  if (isTypeLikeName(s5.name)) score -= 4;
91804
91880
  if (s5.startLine !== void 0 && s5.endLine !== void 0 && s5.endLine - s5.startLine <= 3) {
91805
91881
  score -= 3;
91806
91882
  }
91807
91883
  if (changedFiles !== void 0 && changedFiles.has(s5.filePath)) score += 5;
91808
- if (/\.(resolver|controller|service)\.[jt]sx?$/i.test(s5.filePath)) score += 2;
91884
+ if (/\.(resolver|controller|service)\.[jt]sx?$/i.test(s5.filePath)) score += hintHasCode ? 0 : 2;
91809
91885
  if (/(^|\/)scripts?\//i.test(s5.filePath)) score -= 2;
91810
- score += Math.max(0, 5 - index2) * 0.1;
91886
+ score += Math.max(0, 5 - index2) * (hintHasCode ? 0.8 : 0.1);
91887
+ if (s5.score !== void 0) score += s5.score * (hintHasCode ? 8 : 1.5);
91888
+ if (/\.[jt]sx?$/i.test(s5.name)) score -= 5;
91811
91889
  if (hintTokens !== void 0 && hintTokens.length > 0) {
91812
91890
  let hintBoost = 0;
91813
91891
  for (const tok of hintTokens) {
@@ -91820,10 +91898,10 @@ function scoreSeed(s5, index2, hintTokens, changedFiles) {
91820
91898
  }
91821
91899
  return score;
91822
91900
  }
91823
- function rankSeeds(seeds, hintTokens, changedFiles) {
91901
+ function rankSeeds(seeds, hintTokens, changedFiles, hintHasCode) {
91824
91902
  return seeds.map((symbol, i3) => ({
91825
91903
  symbol,
91826
- score: scoreSeed(symbol, i3, hintTokens, changedFiles),
91904
+ score: scoreSeed(symbol, i3, hintTokens, changedFiles, hintHasCode),
91827
91905
  role: seedRole(symbol),
91828
91906
  i: i3
91829
91907
  })).sort((a2, b5) => b5.score === a2.score ? a2.i - b5.i : b5.score - a2.score).map(({ symbol, score, role }) => ({ symbol, score, role }));
@@ -92307,6 +92385,51 @@ function looksExplanatory(hint) {
92307
92385
  }
92308
92386
  return false;
92309
92387
  }
92388
+ function looksPerformance(hint) {
92389
+ return /\b(slow|slowness|sluggish|laggy|\blag\b|latency|perf|performance|throughput|degraded|degradation|response\s*time|p9[59])\b/i.test(
92390
+ hint
92391
+ );
92392
+ }
92393
+ function buildBehavioralWalkthrough(hint, top, ctx, flows) {
92394
+ const shortFile = (f5) => f5.replace(/^.*?((?:src|app|lib|packages)\/.*)$/, "$1");
92395
+ const isNoise = (s5) => /logger|logging|\.log\b|telemetry/i.test(s5.filePath) || /^(forcontext|getlogger|child|debug|info|warn|error|log|trace|tracer)$/i.test(s5.name);
92396
+ const flow = flows.slice().sort((a2, b5) => b5.steps.length - a2.steps.length)[0];
92397
+ const rawSteps = flow && flow.steps.length > 1 ? flow.steps : top ? [top, ...ctx?.callees ?? []] : ctx?.callees ?? [];
92398
+ const steps = rawSteps.filter((s5) => !isNoise(s5));
92399
+ const entry2 = steps[0] ?? top ?? null;
92400
+ const dedup = (xs) => {
92401
+ const seen = /* @__PURE__ */ new Set();
92402
+ return xs.filter((s5) => {
92403
+ const k3 = `${s5.name}:${s5.filePath}`;
92404
+ if (seen.has(k3)) return false;
92405
+ seen.add(k3);
92406
+ return true;
92407
+ });
92408
+ };
92409
+ const PERSIST_RE = /(^|[._])(save|insert|upsert|persist|bulkwrite|find|findone|findmany|aggregate|delete|remove)\b|repositor|\brepo\b|\.model|entity|prisma|mongoose|typeorm|knex|\bdao\b|datasource/i;
92410
+ const EXTERNAL_RE = /(^|[._])(request|publish|enqueue|dispatch|emit|notify)\b|http|axios|\bapi\b|webhook|\bqueue\b|processor|producer|gateway|\.client\b/i;
92411
+ const pool = dedup([...steps, ...ctx?.callees ?? []]).filter((s5) => !isNoise(s5));
92412
+ const persistence = dedup(pool.filter((s5) => PERSIST_RE.test(s5.name) || PERSIST_RE.test(s5.filePath))).slice(0, 6).map((s5) => `${s5.name} (${shortFile(s5.filePath)})`);
92413
+ const persistNames = new Set(persistence.map((p5) => p5.split(" (")[0]));
92414
+ const externalCalls = dedup(
92415
+ pool.filter((s5) => (EXTERNAL_RE.test(s5.name) || EXTERNAL_RE.test(s5.filePath)) && !persistNames.has(s5.name))
92416
+ ).slice(0, 6).map((s5) => `${s5.name} (${shortFile(s5.filePath)})`);
92417
+ const lines = [];
92418
+ if (entry2) {
92419
+ const loc = entry2.startLine ? `${shortFile(entry2.filePath)}:${entry2.startLine}` : shortFile(entry2.filePath);
92420
+ lines.push(`Entry point: \`${entry2.name}\` (${loc}).`);
92421
+ }
92422
+ if (steps.length > 1) {
92423
+ const chain = steps.slice(0, 12).map((s5) => s5.name).join(" \u2192 ");
92424
+ lines.push(`Flow: ${chain}${steps.length > 12 ? " \u2192 \u2026" : ""}${flow ? ` (${flow.steps.length} steps)` : ""}.`);
92425
+ } else if (entry2) {
92426
+ lines.push(`No multi-step execution flow was indexed for \`${entry2.name}\` \u2014 showing its direct calls.`);
92427
+ }
92428
+ if (externalCalls.length > 0) lines.push(`Calls out to: ${externalCalls.join(", ")}.`);
92429
+ if (persistence.length > 0) lines.push(`Persists via: ${persistence.join(", ")}.`);
92430
+ if (lines.length === 0) lines.push("No execution flow could be reconstructed for this query from the source graph.");
92431
+ return { question: hint, entry: entry2, steps, externalCalls, persistence, narrative: lines.join("\n") };
92432
+ }
92310
92433
  function formatRegressionCitation(recentChanges, changes, seedFile) {
92311
92434
  const matchesSeed = (f5) => seedFile === void 0 || f5 === seedFile || seedFile.endsWith(f5) || f5.endsWith(seedFile);
92312
92435
  const all = recentChanges?.commits ?? [];
@@ -92366,13 +92489,14 @@ async function investigate(input, deps) {
92366
92489
  const changedFilePaths = recentChanges && recentChanges.changedFiles.length > 0 ? new Set(recentChanges.changedFiles) : void 0;
92367
92490
  const rawSeeds = code ? await code.searchSymbols(hint, 5) : [];
92368
92491
  const hintTokens = [...new Set(tokenize(hint))];
92369
- const ranked = rankSeeds(rawSeeds, hintTokens, changedFilePaths);
92492
+ const hintHasCode = /\b(?=[A-Z0-9_]*[0-9_])[A-Z][A-Z0-9_]{3,}\b/.test(hint);
92493
+ const ranked = rankSeeds(rawSeeds, hintTokens, changedFilePaths, hintHasCode);
92370
92494
  let seeds = ranked.map((r) => r.symbol);
92371
92495
  let top = seeds[0];
92372
92496
  if (top && code && isTypeLikeName(top.name)) {
92373
92497
  const base2 = executableBaseName(top.name);
92374
92498
  if (base2) {
92375
- const altTop = rankSeeds(await code.searchSymbols(base2, 5), hintTokens).find(
92499
+ const altTop = rankSeeds(await code.searchSymbols(base2, 5), hintTokens, void 0, hintHasCode).find(
92376
92500
  (r) => !isTypeLikeName(r.symbol.name)
92377
92501
  )?.symbol;
92378
92502
  if (altTop && altTop.id !== top.id) {
@@ -92432,7 +92556,8 @@ async function investigate(input, deps) {
92432
92556
  "slow"
92433
92557
  ]);
92434
92558
  const meaningfulHintTokens = hintTokens.filter((t2) => !GENERIC_HINT_TOKENS.has(t2));
92435
- const seedIsLowConfidence = top !== void 0 && logReseed === null && meaningfulHintTokens.length > 0 && !meaningfulHintTokens.some((t2) => seedHay.includes(t2));
92559
+ const seedIsLowConfidence = top !== void 0 && logReseed === null && (top.score ?? 0) < 0.5 && // a strong exact-content/colocated source match is authoritative (gap 6)
92560
+ meaningfulHintTokens.length > 0 && !meaningfulHintTokens.some((t2) => seedHay.includes(t2));
92436
92561
  if (!top && !degradedNoSource) {
92437
92562
  const report2 = {
92438
92563
  id: globalThis.crypto.randomUUID(),
@@ -92569,6 +92694,8 @@ async function investigate(input, deps) {
92569
92694
  const directLogEvIds = [];
92570
92695
  const ambientLogEvIds = [];
92571
92696
  const directSignatures = [];
92697
+ const directEvIdByKey = /* @__PURE__ */ new Map();
92698
+ const seedEmittedJoins = [];
92572
92699
  const entityEvIds = [];
92573
92700
  const entitySummaries = [];
92574
92701
  let logsCollected = false;
@@ -92643,13 +92770,17 @@ async function investigate(input, deps) {
92643
92770
  if (relevanceClass === "direct") {
92644
92771
  directLogEvIds.push(ev.id);
92645
92772
  directSignatures.push({ key: s5.key, count: s5.count, message: s5.sampleMessage ?? "" });
92773
+ if (!directEvIdByKey.has(s5.key)) directEvIdByKey.set(s5.key, ev.id);
92646
92774
  } else ambientLogEvIds.push(ev.id);
92647
92775
  }
92648
- const seedSnippet = ctx?.snippet !== void 0 && ctx.snippet !== null ? ctx.snippet : void 0;
92776
+ const seedSnippet = (ctx?.sourceBody !== void 0 && ctx.sourceBody !== null ? ctx.sourceBody : void 0) ?? (ctx?.snippet !== void 0 && ctx.snippet !== null ? ctx.snippet : void 0);
92649
92777
  const sourceCodes = extractEventCodes(input.hint, top?.name, seedSnippet);
92778
+ const seedEmittedCodes = new Set(
92779
+ seedSnippet !== void 0 ? extractEventCodes(seedSnippet) : []
92780
+ );
92650
92781
  const joinedCodes = new Set(directSignatures.map((d3) => d3.key));
92651
92782
  for (const code2 of sourceCodes) {
92652
- if (joinedCodes.has(code2)) continue;
92783
+ const alreadyJoined = joinedCodes.has(code2);
92653
92784
  joinedCodes.add(code2);
92654
92785
  try {
92655
92786
  const codeAnalysis = await deps.logs.analyzeErrors({
@@ -92659,6 +92790,21 @@ async function investigate(input, deps) {
92659
92790
  });
92660
92791
  const sig = codeAnalysis.signatures.find((s5) => s5.key === code2);
92661
92792
  if (sig === void 0 || sig.count <= 0) continue;
92793
+ if (alreadyJoined) {
92794
+ if (seedEmittedCodes.has(code2)) {
92795
+ const existingEvId = directEvIdByKey.get(code2);
92796
+ if (existingEvId !== void 0) {
92797
+ seedEmittedJoins.push({
92798
+ code: code2,
92799
+ count: sig.count,
92800
+ message: sig.sampleMessage ?? "",
92801
+ evId: existingEvId,
92802
+ isNew: sig.isNew ?? false
92803
+ });
92804
+ }
92805
+ }
92806
+ continue;
92807
+ }
92662
92808
  const links = top !== void 0 ? top.startLine !== void 0 && top.startLine > 0 ? { symbolId: top.id, file: top.filePath, line: top.startLine } : { symbolId: top.id, file: top.filePath } : {};
92663
92809
  const ev = mkEv(
92664
92810
  "log",
@@ -92677,7 +92823,8 @@ async function investigate(input, deps) {
92677
92823
  sampleMessage: sig.sampleMessage ?? null,
92678
92824
  relevanceClass: "direct",
92679
92825
  relevanceReason: `exact event_code match to the source signal (${code2})`,
92680
- crossSignalJoin: true
92826
+ crossSignalJoin: true,
92827
+ seedEmitted: seedEmittedCodes.has(code2)
92681
92828
  },
92682
92829
  links,
92683
92830
  sig.lastSeen || void 0,
@@ -92692,6 +92839,16 @@ async function investigate(input, deps) {
92692
92839
  count: sig.count,
92693
92840
  message: sig.sampleMessage ?? ""
92694
92841
  });
92842
+ if (!directEvIdByKey.has(code2)) directEvIdByKey.set(code2, ev.id);
92843
+ if (seedEmittedCodes.has(code2)) {
92844
+ seedEmittedJoins.push({
92845
+ code: code2,
92846
+ count: sig.count,
92847
+ message: sig.sampleMessage ?? "",
92848
+ evId: ev.id,
92849
+ isNew: sig.isNew ?? false
92850
+ });
92851
+ }
92695
92852
  } catch {
92696
92853
  }
92697
92854
  }
@@ -93277,6 +93434,29 @@ async function investigate(input, deps) {
93277
93434
  metadata: { blastRadius }
93278
93435
  });
93279
93436
  }
93437
+ if (seedEmittedJoins.length > 0 && top && seedEv) {
93438
+ const ordered = [...seedEmittedJoins].sort((a2, b5) => b5.count - a2.count);
93439
+ const lead = ordered[0];
93440
+ if (lead !== void 0) {
93441
+ const sourceEvidenceIds = [
93442
+ .../* @__PURE__ */ new Set([...ordered.map((j3) => j3.evId), seedEv.id])
93443
+ ];
93444
+ const volumeBoost = Math.min(0.1, Math.log10(Math.max(1, lead.count)) * 0.025);
93445
+ const baseScore = clamp015(0.6 + volumeBoost);
93446
+ const msgClause = lead.message ? ` \u2014 "${lead.message.replace(/\s+/g, " ").trim().slice(0, 90)}"` : "";
93447
+ causeInputs.push({
93448
+ id: "cause:seed-emitted-error",
93449
+ title: `Runtime error ${lead.code} (${lead.count}x in Elasticsearch) is raised by ${top.name} \u2014 the likely failure${msgClause}`.slice(
93450
+ 0,
93451
+ 220
93452
+ ),
93453
+ category: "error-correlation",
93454
+ sourceEvidenceIds,
93455
+ baseScore,
93456
+ metadata: { blastRadius, seedEmitted: true, code: lead.code, count: lead.count }
93457
+ });
93458
+ }
93459
+ }
93280
93460
  if (analysis !== null && analysis.signatures.length > 0 && queueHits.length > 0 && directLogEvIds.length > 0) {
93281
93461
  const firstQueue = queueHits[0];
93282
93462
  const queueLabel = firstQueue !== void 0 ? `"${firstQueue.queueName}" (${firstQueue.producerSymbol ?? "unknown"} -> ${firstQueue.workerSymbol ?? "unknown"})` : "the queue path";
@@ -93395,16 +93575,34 @@ async function investigate(input, deps) {
93395
93575
  const seedDisclaimer = seedIsLowConfidence && top ? `\u26A0 No symbol closely matched "${hint}" \u2014 "${top.name}" is a low-confidence closest match (semantic). Refine with an exact symbol or error code to target precisely. ` : "";
93396
93576
  let summary = headlineCause ? headlineLinked ? `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. Top suspected cause: ${headlineCause.title}.` : `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. No cause is structurally linked to the seed; the strongest co-occurring signal (a lead to verify, not a diagnosis) is: ${headlineCause.title}.` : `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. No dominant suspected cause emerged from the available ${degradedNoSource ? "runtime" : "structural"} evidence.`;
93397
93577
  const hasIncidentSignal = directLogEvIds.length > 0 || latencyMetricEvIds.length > 0 || queueMetricEvIds.length > 0 || dataAnomalyEvIds.length > 0 || queueFailureSignals.length > 0 || findings2.some((f5) => f5.kind === "anomaly") || headlineCause !== void 0;
93398
- const explanatoryHint = looksExplanatory(hint) && !hasIncidentSignal;
93578
+ const explanatoryHint = looksExplanatory(hint);
93579
+ let behavioral;
93399
93580
  if (explanatoryHint) {
93400
- const explainTarget = top?.name ?? input.service ?? hint;
93401
- summary = `${seedDisclaimer}${banner2}"${hint}" reads as a "how does it work" question and no incident signal was found (no error signatures, anomalies, or failing queues). For a focused answer, run \`horus explain ${explainTarget}\`. ${top ? `Investigated ${label} (${area}); the` : "The"} incident sections below are empty by design \u2014 this is not a fault hunt.`;
93581
+ let flowPool = flows;
93582
+ const richestSeedFlow = flows.slice().sort((a2, b5) => b5.steps.length - a2.steps.length)[0];
93583
+ if (code && (richestSeedFlow === void 0 || richestSeedFlow.steps.length <= 1) && ctx?.callers?.length) {
93584
+ const ENTRY_RE = /controller|resolver|route|handler|command|consumer|listener|gateway|cron|scheduler|webhook|middleware/i;
93585
+ const entryCallers = ctx.callers.filter((c4) => ENTRY_RE.test(c4.name) || ENTRY_RE.test(c4.filePath)).slice(0, 3);
93586
+ for (const c4 of entryCallers) {
93587
+ try {
93588
+ flowPool = flowPool.concat(await code.flowsFor(c4.id));
93589
+ } catch {
93590
+ }
93591
+ }
93592
+ }
93593
+ behavioral = buildBehavioralWalkthrough(hint, top, ctx ?? void 0, flowPool);
93594
+ const noisy = hasIncidentSignal ? ' (note: live error signals also exist in this codebase \u2014 rephrase as a symptom like "X failing" for a fault hunt)' : "";
93595
+ summary = `${seedDisclaimer}${banner2}"${hint}" reads as a "how does it work" question \u2014 here is the code path${noisy}:
93596
+
93597
+ ` + behavioral.narrative;
93598
+ }
93599
+ if (!explanatoryHint && looksPerformance(hint) && deps.metrics == null && seedIsLowConfidence) {
93600
+ summary = `${banner2}"${hint}" reads as a latency/performance question, but no metrics connector (Grafana) is configured \u2014 response-time and error-rate trends can't be assessed from source structure alone${top ? ` (the closest structural match, ${top.name}, is a low-confidence guess, not a diagnosis)` : ""}. Connect a metrics source (\`horus connect grafana\`) and re-run, or name the specific slow operation.`;
93402
93601
  }
93403
93602
  const nextActions = buildNextActions(top, ctx, impact, queueHits, changes, input);
93404
- if (explanatoryHint) {
93405
- const explainTarget = top?.name ?? input.service ?? hint;
93603
+ if (explanatoryHint && behavioral?.entry) {
93406
93604
  nextActions.unshift(
93407
- `This looks like a behavioral question \u2014 run \`horus explain ${explainTarget}\` for a focused walkthrough of how it works.`
93605
+ `Trace the full call graph of the entry point: \`horus explain ${behavioral.entry.name}\`.`
93408
93606
  );
93409
93607
  }
93410
93608
  if (degradedNoSource) {
@@ -93436,6 +93634,7 @@ async function investigate(input, deps) {
93436
93634
  ownership: ownershipEstimate,
93437
93635
  causeChains: causeChains.length > 0 ? causeChains : void 0,
93438
93636
  ...recentChanges !== void 0 ? { recentChanges } : {},
93637
+ ...behavioral !== void 0 ? { behavioral } : {},
93439
93638
  ...degradedNoSource ? { degraded: { sourceIntelligence: false, reason: "source-intelligence host unreachable" } } : {}
93440
93639
  };
93441
93640
  const connectorFlags = deps.connectors ? {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {