@merittdev/horus 0.10.0 → 0.11.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 +745 -19
  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.10.0" : "dev";
65883
+ var HORUS_VERSION = true ? "0.11.0" : "dev";
65884
65884
  var PINNED_SOURCE_VERSION = "1.6.1";
65885
65885
 
65886
65886
  // ../../packages/core/src/evidence.ts
@@ -70251,6 +70251,22 @@ var connectorsSchema = external_exports.object({
70251
70251
  /** Env var holding the base URL. Defaults to "SENTRY_URL". */
70252
70252
  urlEnv: external_exports.string().optional()
70253
70253
  }).optional(),
70254
+ /**
70255
+ * Axiom logs connector (HOR-429). Structured log rows queried with APL become
70256
+ * `kind: 'log'` evidence (message + level + fields), alongside Elasticsearch.
70257
+ */
70258
+ axiom: external_exports.object({
70259
+ /** Dataset to query (the Axiom analogue of an ES index). */
70260
+ dataset: external_exports.string(),
70261
+ /** Direct API token (takes priority over tokenEnv). */
70262
+ token: external_exports.string().optional(),
70263
+ /** Env var holding the API token. Defaults to "AXIOM_TOKEN". */
70264
+ tokenEnv: external_exports.string().optional(),
70265
+ /** Direct base URL (US default https://api.axiom.co; EU https://api.eu.axiom.co). Takes priority over urlEnv. */
70266
+ url: external_exports.string().optional(),
70267
+ /** Env var holding the base URL. Defaults to "AXIOM_URL". */
70268
+ urlEnv: external_exports.string().optional()
70269
+ }).optional(),
70254
70270
  grafana: external_exports.object({
70255
70271
  dashboard: external_exports.string().optional(),
70256
70272
  /** Multiple dashboard UIDs to fetch. Takes precedence over `dashboard` when set. */
@@ -70469,6 +70485,16 @@ function resolveEnvironment(config2, opts) {
70469
70485
  ...url !== void 0 ? { url } : {}
70470
70486
  };
70471
70487
  }
70488
+ if (c4.axiom !== void 0) {
70489
+ const a2 = c4.axiom;
70490
+ const token = a2.token ?? process.env[a2.tokenEnv ?? "AXIOM_TOKEN"];
70491
+ const url = a2.url ?? process.env[a2.urlEnv ?? "AXIOM_URL"];
70492
+ resolved.axiom = {
70493
+ dataset: a2.dataset,
70494
+ ...token !== void 0 ? { token } : {},
70495
+ ...url !== void 0 ? { url } : {}
70496
+ };
70497
+ }
70472
70498
  if (c4.grafana !== void 0) {
70473
70499
  const g4 = c4.grafana;
70474
70500
  resolved.grafana = {
@@ -73724,6 +73750,296 @@ function shortTs2(iso) {
73724
73750
  return `${iso.slice(5, 10)} ${iso.slice(11, 16)}`;
73725
73751
  }
73726
73752
 
73753
+ // ../../packages/connectors/src/axiom/client.ts
73754
+ init_cjs_shims();
73755
+ var REQUEST_TIMEOUT_MS2 = 8e3;
73756
+ var APL_PATH = "/v1/datasets/_apl?format=tabular";
73757
+ var AxiomClient = class {
73758
+ baseUrl;
73759
+ token;
73760
+ dataset;
73761
+ constructor(opts) {
73762
+ this.baseUrl = (opts.baseUrl ?? "https://api.axiom.co").replace(/\/$/, "");
73763
+ this.token = opts.token;
73764
+ this.dataset = opts.dataset;
73765
+ }
73766
+ async request(method, path, body2) {
73767
+ const url = `${this.baseUrl}${path}`;
73768
+ const init2 = {
73769
+ method,
73770
+ headers: {
73771
+ Authorization: `Bearer ${this.token}`,
73772
+ "Content-Type": "application/json"
73773
+ },
73774
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
73775
+ };
73776
+ if (body2 !== void 0) init2.body = JSON.stringify(body2);
73777
+ const res = await fetch(url, init2);
73778
+ if (!res.ok) {
73779
+ const text2 = await res.text().catch(() => "");
73780
+ throw new Error(`Axiom ${method} ${path} -> ${res.status}: ${text2.slice(0, 200)}`);
73781
+ }
73782
+ return res.json();
73783
+ }
73784
+ /**
73785
+ * List the datasets reachable with the configured token. Returns [] on any error so
73786
+ * callers degrade gracefully (never throws past here).
73787
+ */
73788
+ async listDatasets() {
73789
+ try {
73790
+ const raw = await this.request("GET", "/v1/datasets");
73791
+ if (!Array.isArray(raw)) return [];
73792
+ return raw.map(parseDataset);
73793
+ } catch {
73794
+ return [];
73795
+ }
73796
+ }
73797
+ /**
73798
+ * Run an APL query over the configured time window. `startTime`/`endTime` are ISO
73799
+ * strings and bound the query server-side. Returns parsed log records, or [] on any
73800
+ * failure (never throws past here).
73801
+ */
73802
+ async query(apl, startTime, endTime) {
73803
+ try {
73804
+ const raw = await this.request("POST", APL_PATH, { apl, startTime, endTime });
73805
+ return parseTabular(raw);
73806
+ } catch {
73807
+ return [];
73808
+ }
73809
+ }
73810
+ /**
73811
+ * Cheap reachability probe: list datasets. Returns a structured status; never throws.
73812
+ */
73813
+ async health() {
73814
+ try {
73815
+ await this.request("GET", "/v1/datasets");
73816
+ return { ok: true, detail: `axiom reachable (${this.dataset})` };
73817
+ } catch (err2) {
73818
+ return { ok: false, detail: err2.message };
73819
+ }
73820
+ }
73821
+ };
73822
+ function parseDataset(raw) {
73823
+ const ds = { name: String(raw["name"] ?? "") };
73824
+ if (typeof raw["description"] === "string" && raw["description"] !== "") {
73825
+ ds.description = raw["description"];
73826
+ }
73827
+ return ds;
73828
+ }
73829
+ var AXIOM_ERROR_LEVELS = ["error", "warn", "fatal"];
73830
+ function datasetRef(dataset) {
73831
+ return `['${dataset.replace(/'/g, "")}']`;
73832
+ }
73833
+ function searchTerms(hintTerms) {
73834
+ return hintTerms.map((t2) => t2.trim()).filter((t2) => t2.length > 2);
73835
+ }
73836
+ function searchClause(hintTerms) {
73837
+ const terms = searchTerms(hintTerms);
73838
+ return terms.length > 0 ? ` | search ${terms.map((t2) => `"${t2.replace(/"/g, "")}"`).join(" or ")}` : "";
73839
+ }
73840
+ function errorLevelClause() {
73841
+ const levels = AXIOM_ERROR_LEVELS.map((l2) => `'${l2}'`).join(", ");
73842
+ return ` | where tolower(tostring(level)) in (${levels})`;
73843
+ }
73844
+ function buildApl(dataset, hintTerms = [], limit = 100) {
73845
+ const lim = Math.max(1, Math.min(limit, 1e3));
73846
+ return `${datasetRef(dataset)}${searchClause(hintTerms)} | sort by _time desc | limit ${lim}`;
73847
+ }
73848
+ function buildErrorSignatureApl(dataset, hintTerms = [], topN = 10) {
73849
+ const lim = Math.max(1, Math.min(topN, 1e3));
73850
+ return `${datasetRef(dataset)}${errorLevelClause()}${searchClause(hintTerms)} | summarize ['count'] = count(), ['_time'] = max(_time) by message, level | sort by ['count'] desc | limit ${lim}`;
73851
+ }
73852
+ function buildRecentErrorsApl(dataset, hintTerms = [], limit = 5) {
73853
+ const lim = Math.max(1, Math.min(limit, 1e3));
73854
+ return `${datasetRef(dataset)}${errorLevelClause()}${searchClause(hintTerms)} | sort by _time desc | limit ${lim}`;
73855
+ }
73856
+ function parseTabular(raw) {
73857
+ if (raw === null || raw === void 0 || typeof raw !== "object") return [];
73858
+ const tables = raw["tables"];
73859
+ if (!Array.isArray(tables)) return [];
73860
+ const records = [];
73861
+ for (const table of tables) {
73862
+ const fields = table["fields"];
73863
+ if (!Array.isArray(fields)) continue;
73864
+ const names = fields.map((f5) => String(f5["name"] ?? ""));
73865
+ const columns = table["columns"];
73866
+ const rows = table["rows"];
73867
+ if (Array.isArray(columns)) {
73868
+ const first = columns[0];
73869
+ const rowCount = Array.isArray(first) ? first.length : 0;
73870
+ for (let r = 0; r < rowCount; r++) {
73871
+ const row = {};
73872
+ for (let c4 = 0; c4 < names.length; c4++) {
73873
+ const col = columns[c4];
73874
+ if (Array.isArray(col)) row[names[c4]] = col[r];
73875
+ }
73876
+ records.push(toRecord(row));
73877
+ }
73878
+ } else if (Array.isArray(rows)) {
73879
+ for (const rowVals of rows) {
73880
+ if (!Array.isArray(rowVals)) continue;
73881
+ const row = {};
73882
+ for (let c4 = 0; c4 < names.length; c4++) {
73883
+ row[names[c4]] = rowVals[c4];
73884
+ }
73885
+ records.push(toRecord(row));
73886
+ }
73887
+ }
73888
+ }
73889
+ return records;
73890
+ }
73891
+ function toRecord(fields) {
73892
+ const rec = { fields };
73893
+ const t2 = fields["_time"];
73894
+ if (typeof t2 === "string") rec.timestamp = t2;
73895
+ return rec;
73896
+ }
73897
+
73898
+ // ../../packages/connectors/src/axiom/provider.ts
73899
+ init_cjs_shims();
73900
+ var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1e3;
73901
+ var AxiomProvider = class {
73902
+ constructor(client, opts) {
73903
+ this.client = client;
73904
+ this.opts = opts;
73905
+ }
73906
+ client;
73907
+ opts;
73908
+ id = "axiom";
73909
+ kind = "logs";
73910
+ /**
73911
+ * Collect log rows over the window, INCIDENT-AWARE. Rather than a broad newest-first
73912
+ * scan (which on a noisy dataset returns the 15 freshest `info` rows and misses a
73913
+ * high-volume error storm), this runs two error-biased queries in parallel:
73914
+ *
73915
+ * 1. TOP ERROR SIGNATURES — `summarize count() + max(_time) by message, level` over
73916
+ * error/warn/fatal rows (AND the hint terms), highest-volume first. This is what
73917
+ * surfaces a 3k-row `level=error` signature that a newest-first scan buries.
73918
+ * 2. RECENT RAW ERRORS — a few newest error rows with full fields, for grounding.
73919
+ *
73920
+ * Signatures lead so high-volume errors aren't crowded out. When NO error-level row
73921
+ * matches (neither query returns anything) it broadens to the all-levels fallback so
73922
+ * non-error datasets still produce evidence. Best-effort: a failing query yields [].
73923
+ * Never throws.
73924
+ */
73925
+ async collect(opts = {}) {
73926
+ const to = opts.to ?? (/* @__PURE__ */ new Date()).toISOString();
73927
+ const from = opts.from ?? new Date(Date.parse(to) - (this.opts.windowMs ?? DEFAULT_WINDOW_MS)).toISOString();
73928
+ const hintTerms = opts.hintTerms ?? [];
73929
+ const limit = this.opts.limit ?? 100;
73930
+ const topN = Math.max(1, Math.min(limit, 10));
73931
+ const recentN = Math.max(1, Math.min(limit, 5));
73932
+ const [signatures, recent] = await Promise.all([
73933
+ this.client.query(buildErrorSignatureApl(this.opts.dataset, hintTerms, topN), from, to),
73934
+ this.client.query(buildRecentErrorsApl(this.opts.dataset, hintTerms, recentN), from, to)
73935
+ ]);
73936
+ if (signatures.length === 0 && recent.length === 0) {
73937
+ return this.client.query(buildApl(this.opts.dataset, hintTerms, limit), from, to);
73938
+ }
73939
+ return [...signatures, ...recent];
73940
+ }
73941
+ /**
73942
+ * Synthesize Evidence from Axiom rows. One `kind: 'log'` Evidence per row, with the
73943
+ * row fields on the payload (under `source: 'axiom'`), a redacted message in the
73944
+ * title, and relevance weighted by hint-term match / recency / level.
73945
+ */
73946
+ toEvidence(records, hintTerms, collectedAt) {
73947
+ const query = `axiom ${this.opts.dataset}`;
73948
+ return records.map(
73949
+ (rec, i3) => this.recordToEvidence(rec, hintTerms, query, collectedAt, i3)
73950
+ );
73951
+ }
73952
+ /**
73953
+ * One-shot evidence query: collect rows + convert to Evidence. Preferred entry point
73954
+ * for the investigation engine. Degrades to [] on any failure so a flaky Axiom never
73955
+ * aborts an investigation.
73956
+ */
73957
+ async queryEvidence(opts = {}) {
73958
+ try {
73959
+ const hintTerms = opts.hintTerms ?? [];
73960
+ const records = await this.collect({
73961
+ ...opts.from !== void 0 ? { from: opts.from } : {},
73962
+ ...opts.to !== void 0 ? { to: opts.to } : {},
73963
+ hintTerms
73964
+ });
73965
+ return this.toEvidence(records, hintTerms, opts.collectedAt ?? (/* @__PURE__ */ new Date()).toISOString());
73966
+ } catch {
73967
+ return [];
73968
+ }
73969
+ }
73970
+ async health() {
73971
+ return this.client.health();
73972
+ }
73973
+ recordToEvidence(rec, hintTerms, query, collectedAt, index2) {
73974
+ const title = buildTitle2(rec, this.opts.dataset);
73975
+ const relevance = computeRelevance2(rec, hintTerms);
73976
+ const payload = {
73977
+ source: "axiom",
73978
+ dataset: this.opts.dataset,
73979
+ ...rec.fields
73980
+ };
73981
+ const ev = {
73982
+ id: `ev_axiom_${index2}`,
73983
+ source: "logs",
73984
+ kind: "log",
73985
+ title,
73986
+ relevance,
73987
+ payload,
73988
+ links: {},
73989
+ provenance: { query, collectedAt }
73990
+ };
73991
+ if (rec.timestamp !== void 0) ev.timestamp = rec.timestamp;
73992
+ return ev;
73993
+ }
73994
+ };
73995
+ var MESSAGE_FIELDS = ["message", "msg", "body", "event", "log", "_raw"];
73996
+ var LEVEL_FIELDS = ["level", "severity", "lvl", "loglevel", "status"];
73997
+ function pickField2(fields, candidates) {
73998
+ for (const key of candidates) {
73999
+ const v4 = fields[key];
74000
+ if (typeof v4 === "string" && v4 !== "") return v4;
74001
+ if (typeof v4 === "number") return String(v4);
74002
+ }
74003
+ return void 0;
74004
+ }
74005
+ function buildTitle2(rec, dataset) {
74006
+ const msg = pickField2(rec.fields, MESSAGE_FIELDS);
74007
+ const level = pickField2(rec.fields, LEVEL_FIELDS);
74008
+ const lvl = level ? `[${level}] ` : "";
74009
+ const body2 = msg ? redactSecrets(msg) : "(log event)";
74010
+ const rawCount = rec.fields["count"];
74011
+ const count = typeof rawCount === "number" && Number.isFinite(rawCount) && rawCount > 1 ? rawCount : void 0;
74012
+ if (count !== void 0) {
74013
+ const latest = rec.timestamp ? ` (latest ${shortTs3(rec.timestamp)})` : "";
74014
+ return `Axiom ${dataset}: ${lvl}${body2} \xD7${count.toLocaleString("en-US")}${latest}`.slice(0, 220);
74015
+ }
74016
+ const ts = rec.timestamp ? ` \xB7 ${shortTs3(rec.timestamp)}` : "";
74017
+ return `Axiom ${dataset}: ${lvl}${body2}${ts}`.slice(0, 220);
74018
+ }
74019
+ function computeRelevance2(rec, hintTerms, now = Date.now()) {
74020
+ let score = 0.6;
74021
+ const hay = JSON.stringify(rec.fields).toLowerCase();
74022
+ const domainTerms = hintTerms.filter((t2) => t2.length > 2);
74023
+ if (domainTerms.some((t2) => hay.includes(t2.toLowerCase()))) score += 0.2;
74024
+ if (rec.timestamp) {
74025
+ const ageMs = now - Date.parse(rec.timestamp);
74026
+ if (Number.isFinite(ageMs)) {
74027
+ if (ageMs <= 864e5) score += 0.1;
74028
+ else if (ageMs <= 7 * 864e5) score += 0.05;
74029
+ }
74030
+ }
74031
+ const level = (pickField2(rec.fields, LEVEL_FIELDS) ?? "").toLowerCase();
74032
+ if (level === "error" || level === "fatal" || level === "critical") score += 0.1;
74033
+ else if (level === "warn" || level === "warning") score += 0.05;
74034
+ const count = rec.fields["count"];
74035
+ if (typeof count === "number" && count >= 10) score += 0.1;
74036
+ return Math.min(0.95, Math.max(0.5, score));
74037
+ }
74038
+ function shortTs3(iso) {
74039
+ if (!iso || iso.length < 16) return iso || "\u2014";
74040
+ return `${iso.slice(5, 10)} ${iso.slice(11, 16)}`;
74041
+ }
74042
+
73727
74043
  // ../../packages/connectors/src/bullmq/client.ts
73728
74044
  init_cjs_shims();
73729
74045
  var import_ioredis = __toESM(require_built3(), 1);
@@ -74388,6 +74704,18 @@ function sentryForEnv(renv) {
74388
74704
  { org: s5.org, project: s5.project }
74389
74705
  );
74390
74706
  }
74707
+ function axiomForEnv(renv) {
74708
+ const a2 = renv.connectors.axiom;
74709
+ if (!a2 || !a2.token || !a2.dataset) return null;
74710
+ return new AxiomProvider(
74711
+ new AxiomClient({
74712
+ token: a2.token,
74713
+ dataset: a2.dataset,
74714
+ ...a2.url !== void 0 ? { baseUrl: a2.url } : {}
74715
+ }),
74716
+ { dataset: a2.dataset }
74717
+ );
74718
+ }
74391
74719
  function codeForRepo(config2, repoName) {
74392
74720
  const renv = resolveEnvironment(config2, { project: repoName });
74393
74721
  const c4 = codeForEnv(renv);
@@ -74563,6 +74891,9 @@ init_cjs_shims();
74563
74891
  // ../../packages/connectors/src/sentry/index.ts
74564
74892
  init_cjs_shims();
74565
74893
 
74894
+ // ../../packages/connectors/src/axiom/index.ts
74895
+ init_cjs_shims();
74896
+
74566
74897
  // ../../packages/connectors/src/bullmq/index.ts
74567
74898
  init_cjs_shims();
74568
74899
 
@@ -90486,6 +90817,32 @@ async function checkEnv(renv, deps) {
90486
90817
  ` ${mark("pending")} ${import_picocolors3.default.bold("Sentry")} ${import_picocolors3.default.dim("not configured")}`
90487
90818
  );
90488
90819
  }
90820
+ const axiomCfg = renv.connectors.axiom;
90821
+ if (axiomCfg) {
90822
+ const label = axiomCfg.dataset ?? "(no dataset)";
90823
+ const axiom = (deps?.axiomFactory ?? axiomForEnv)(renv);
90824
+ if (axiom) {
90825
+ const h3 = await axiom.health();
90826
+ if (!h3.ok) {
90827
+ console.log(
90828
+ ` ${mark(false)} ${import_picocolors3.default.bold("Axiom")} ${import_picocolors3.default.dim(`unreachable \xB7 ${label}`)}`
90829
+ );
90830
+ allOk = false;
90831
+ } else {
90832
+ console.log(
90833
+ ` ${mark(true)} ${import_picocolors3.default.bold("Axiom")} ${import_picocolors3.default.dim(`reachable \xB7 ${label}`)}`
90834
+ );
90835
+ }
90836
+ } else {
90837
+ console.log(
90838
+ ` ${mark(false)} ${import_picocolors3.default.bold("Axiom")} ${import_picocolors3.default.dim(`configured (${label}) but API token not set`)}`
90839
+ );
90840
+ }
90841
+ } else {
90842
+ console.log(
90843
+ ` ${mark("pending")} ${import_picocolors3.default.bold("Axiom")} ${import_picocolors3.default.dim("not configured")}`
90844
+ );
90845
+ }
90489
90846
  const redisCfg = renv.connectors.redis;
90490
90847
  if (redisCfg?.url) {
90491
90848
  const server = redisServerLabel(redisCfg.url);
@@ -92915,12 +93272,15 @@ function detectMissingEvidence(r, connectors = {}, mode) {
92915
93272
  if (!hasLog && !sourceImpact) {
92916
93273
  let logWhy;
92917
93274
  let logNextSource;
92918
- if (!connectors.elasticsearch && !connectors.sentry) {
92919
- logWhy = "No Elasticsearch connector (nor Sentry) configured for this environment \u2014 no runtime error evidence.";
92920
- logNextSource = "Add an `elasticsearch` and/or `sentry` connector to the project/environment";
93275
+ if (!connectors.elasticsearch && !connectors.sentry && !connectors.axiom) {
93276
+ logWhy = "No Elasticsearch connector (nor Sentry / Axiom) configured for this environment \u2014 no runtime error evidence.";
93277
+ logNextSource = "Add an `elasticsearch`, `sentry`, and/or `axiom` connector to the project/environment";
92921
93278
  } else if (!connectors.elasticsearch && connectors.sentry) {
92922
93279
  logWhy = connectors.sentryCollected ? "No open Sentry issues matched in the window \u2014 cannot confirm the actual error signatures." : "Sentry collection failed or timed out \u2014 cannot confirm the actual error signatures.";
92923
93280
  logNextSource = connectors.sentryCollected ? "Widen the window (--since) or check the Sentry project for open issues" : "Check the Sentry auth token / project, then retry";
93281
+ } else if (!connectors.elasticsearch && connectors.axiom) {
93282
+ logWhy = connectors.axiomCollected ? "No Axiom log rows matched in the window \u2014 cannot confirm the actual error signatures." : "Axiom log collection failed or timed out \u2014 cannot confirm the actual error signatures.";
93283
+ logNextSource = connectors.axiomCollected ? "Widen the window (--since) or check the Axiom dataset for matching rows" : "Check the Axiom API token / dataset, then retry";
92924
93284
  } else if (connectors.logsCompatibilityError) {
92925
93285
  logWhy = `Elasticsearch field mapping is incompatible with the index \u2014 log collection was blocked. ` + connectors.logsCompatibilityError;
92926
93286
  logNextSource = "Fix fields.* overrides in your connector config or choose the correct preset (meritt / ecs)";
@@ -92931,7 +93291,7 @@ function detectMissingEvidence(r, connectors = {}, mode) {
92931
93291
  logWhy = "No error logs matched in the window \u2014 cannot confirm the actual error signatures.";
92932
93292
  logNextSource = "Widen the window (--since) or inspect `horus logs <service>`";
92933
93293
  }
92934
- const logRouteHint = !connectors.elasticsearch && !connectors.sentry ? { nextTool: "connect", args: "elasticsearch", reason: logNextSource } : { nextTool: "logs", args: service, reason: logNextSource };
93294
+ const logRouteHint = !connectors.elasticsearch && !connectors.sentry && !connectors.axiom ? { nextTool: "connect", args: "elasticsearch", reason: logNextSource } : { nextTool: "logs", args: service, reason: logNextSource };
92935
93295
  gaps.push({
92936
93296
  dimension: "logs",
92937
93297
  why: logWhy,
@@ -92958,8 +93318,10 @@ function detectMissingEvidence(r, connectors = {}, mode) {
92958
93318
  if (hasQueueTopology && !hasQueueState && !sourceImpact) {
92959
93319
  gaps.push({
92960
93320
  dimension: "queue runtime state",
92961
- why: connectors.redis ? "Queue topology is known but live depth + failed/delayed counts were not collected." : "Queue topology is known but there is no Redis/BullMQ connector for live depth/failures.",
92962
- nextSource: connectors.redis ? "Run `horus queues --live` to see real-time queue depths and failed-job counts" : "Add a `redis` connector to read live BullMQ state",
93321
+ why: connectors.redis ? "Queue topology is known but live depth + failed/delayed counts were not collected." : "Queue topology is known but there is no Redis connector for live queue depth/failures.",
93322
+ // Stack-agnostic: the tip names Redis (the connector to add) but NOT a specific queue
93323
+ // library — "BullMQ" is Node-only and was leaking onto Python/Redis repos (HOR-428).
93324
+ nextSource: connectors.redis ? "Run `horus queues --live` to see real-time queue depths and failed-job counts" : "Add a `redis` connector to read live queue state",
92963
93325
  confidenceImpact: 0.1,
92964
93326
  // Redis wired → read live state with `queues --live`; otherwise `connect redis` first.
92965
93327
  routeHint: connectors.redis ? {
@@ -92969,7 +93331,7 @@ function detectMissingEvidence(r, connectors = {}, mode) {
92969
93331
  } : {
92970
93332
  nextTool: "connect",
92971
93333
  args: "redis",
92972
- reason: "Add a `redis` connector to read live BullMQ state"
93334
+ reason: "Add a `redis` connector to read live queue state"
92973
93335
  }
92974
93336
  });
92975
93337
  blindSpots.push("Cannot determine if the queue is actually backed up.");
@@ -93047,10 +93409,11 @@ function buildEntry(source, configured, evidenceCount, failed, detail) {
93047
93409
  }
93048
93410
  function buildRuntimeSourceStatus(evidence2, connectors) {
93049
93411
  const logsCount = evidence2.filter((e) => e.source === "logs").length;
93050
- const logsConfigured = !!(connectors.elasticsearch || connectors.sentry);
93412
+ const logsConfigured = !!(connectors.elasticsearch || connectors.sentry || connectors.axiom);
93051
93413
  const esFailed = !!connectors.elasticsearch && !connectors.logsCollected;
93052
93414
  const sentryFailed = !!connectors.sentry && !connectors.sentryCollected;
93053
- const logsFailed = logsConfigured && logsCount === 0 && (esFailed || sentryFailed);
93415
+ const axiomFailed = !!connectors.axiom && !connectors.axiomCollected;
93416
+ const logsFailed = logsConfigured && logsCount === 0 && (esFailed || sentryFailed || axiomFailed);
93054
93417
  const metricsCount = evidence2.filter((e) => e.source === "metrics").length;
93055
93418
  const metricsConfigured = !!connectors.grafana;
93056
93419
  const metricsFailed = metricsConfigured && !connectors.metricsCollected;
@@ -94016,6 +94379,17 @@ function classifyLogRelevance(sigKey, sigServices, seedTerms, inputService) {
94016
94379
  relevanceReason: "no structural link to seed \u2014 ambient runtime noise"
94017
94380
  };
94018
94381
  }
94382
+ var AXIOM_MESSAGE_FIELDS = ["message", "msg", "body", "event", "log", "_raw"];
94383
+ var AXIOM_LEVEL_FIELDS = ["level", "severity", "lvl", "loglevel", "status"];
94384
+ var AXIOM_SERVICE_FIELDS = ["service", "app", "logger", "source", "namespace"];
94385
+ function pickAxiomField(fields, candidates) {
94386
+ for (const key of candidates) {
94387
+ const v4 = fields[key];
94388
+ if (typeof v4 === "string" && v4 !== "") return v4;
94389
+ if (typeof v4 === "number") return String(v4);
94390
+ }
94391
+ return void 0;
94392
+ }
94019
94393
  function extractEventCodes(...texts) {
94020
94394
  const RE = /\b(?=[A-Z0-9_]*[0-9_])[A-Z][A-Z0-9_]{3,}\b/g;
94021
94395
  const out2 = [];
@@ -94965,6 +95339,61 @@ async function investigate(input, deps) {
94965
95339
  sentryCollected = false;
94966
95340
  }
94967
95341
  }
95342
+ let axiomCollected = false;
95343
+ if (deps.axiom) {
95344
+ try {
95345
+ const from = logWindowFrom(input.logsSince ?? input.since);
95346
+ const to = (/* @__PURE__ */ new Date()).toISOString();
95347
+ const axiomSeedBase = top ? top.filePath.split("/").pop() ?? "" : "";
95348
+ const axiomTerms = top ? [.../* @__PURE__ */ new Set([...tokenize(hint), ...tokenize(top.name), ...tokenize(axiomSeedBase)])] : [...new Set(tokenize(hint))];
95349
+ const records = await deps.axiom.collect({ from, to, hintTerms: axiomTerms });
95350
+ axiomCollected = true;
95351
+ for (const rec of records.slice(0, 15)) {
95352
+ const msg = pickAxiomField(rec.fields, AXIOM_MESSAGE_FIELDS);
95353
+ const level = pickAxiomField(rec.fields, AXIOM_LEVEL_FIELDS);
95354
+ const svc = pickAxiomField(rec.fields, AXIOM_SERVICE_FIELDS) ?? "";
95355
+ const sigKey = [msg ?? "", svc].filter(Boolean).join(" ");
95356
+ const { relevanceClass, relevanceReason } = classifyLogRelevance(
95357
+ sigKey,
95358
+ svc ? [svc] : [],
95359
+ axiomTerms,
95360
+ input.service
95361
+ );
95362
+ const lvl = level ? `[${level}] ` : "";
95363
+ const body2 = msg ?? "(log event)";
95364
+ const tag = relevanceClass === "ambient" ? " [ambient]" : "";
95365
+ const title = `Axiom log: ${lvl}${body2}${tag}`.slice(0, 220);
95366
+ const isError = ["error", "fatal", "critical"].includes((level ?? "").toLowerCase());
95367
+ const ev = mkEv(
95368
+ "log",
95369
+ title,
95370
+ {
95371
+ source: "axiom",
95372
+ signature: msg ?? null,
95373
+ level: level ?? null,
95374
+ service: svc || null,
95375
+ relevanceClass,
95376
+ relevanceReason,
95377
+ fields: rec.fields
95378
+ },
95379
+ {},
95380
+ rec.timestamp,
95381
+ // Direct: weighted up for error-level rows. Ambient: demoted so unrelated
95382
+ // high-volume log lines don't inflate confidence.
95383
+ relevanceClass === "direct" ? isError ? 0.9 : 0.75 : 0.4
95384
+ );
95385
+ logEvIds.push(ev.id);
95386
+ if (relevanceClass === "direct") {
95387
+ directLogEvIds.push(ev.id);
95388
+ directSignatures.push({ key: msg ?? "(log event)", count: 1, message: msg ?? "" });
95389
+ } else {
95390
+ ambientLogEvIds.push(ev.id);
95391
+ }
95392
+ }
95393
+ } catch {
95394
+ axiomCollected = false;
95395
+ }
95396
+ }
94968
95397
  let stateAnalysis = null;
94969
95398
  const stateEvIds = [];
94970
95399
  const stateCollections = /* @__PURE__ */ new Set();
@@ -95725,6 +96154,10 @@ async function investigate(input, deps) {
95725
96154
  // its collection ran (distinguishes "no open issues" from "collection failed").
95726
96155
  sentry: deps.sentry != null,
95727
96156
  sentryCollected,
96157
+ // Axiom is configured iff a provider was built; axiomCollected tracks whether its
96158
+ // collection ran (distinguishes "no matching rows" from "collection failed").
96159
+ axiom: deps.axiom != null,
96160
+ axiomCollected,
95728
96161
  metricsCollected,
95729
96162
  metricsFailureReason,
95730
96163
  logsCollected,
@@ -95736,6 +96169,8 @@ async function investigate(input, deps) {
95736
96169
  postgres: deps.postgres != null,
95737
96170
  sentry: deps.sentry != null,
95738
96171
  sentryCollected,
96172
+ axiom: deps.axiom != null,
96173
+ axiomCollected,
95739
96174
  grafana: deps.metrics != null,
95740
96175
  queue: deps.queue != null,
95741
96176
  metricsCollected,
@@ -114838,6 +115273,7 @@ async function buildInvestigationContext(renv, opts) {
114838
115273
  const mongo = mongoForEnv(renv);
114839
115274
  const postgres2 = postgresForEnv(renv);
114840
115275
  const sentry = sentryForEnv(renv);
115276
+ const axiom = axiomForEnv(renv);
114841
115277
  const queue = queueForEnv(renv);
114842
115278
  const redisState = redisStateForEnv(renv);
114843
115279
  const metrics = metricsForEnv(renv);
@@ -114854,6 +115290,7 @@ async function buildInvestigationContext(renv, opts) {
114854
115290
  mongo,
114855
115291
  postgres: postgres2,
114856
115292
  sentry,
115293
+ axiom,
114857
115294
  queue,
114858
115295
  redisState,
114859
115296
  metrics,
@@ -114882,6 +115319,7 @@ async function runOneInvestigation(input, ctx, opts = {}) {
114882
115319
  mongo: ctx.mongo,
114883
115320
  postgres: ctx.postgres,
114884
115321
  sentry: ctx.sentry,
115322
+ axiom: ctx.axiom,
114885
115323
  queue: ctx.queue,
114886
115324
  redisState: ctx.redisState,
114887
115325
  metrics: ctx.metrics,
@@ -114892,6 +115330,7 @@ async function runOneInvestigation(input, ctx, opts = {}) {
114892
115330
  mongodb: !!renv.connectors.mongodb?.url,
114893
115331
  postgres: !!renv.connectors.postgres?.url,
114894
115332
  sentry: !!renv.connectors.sentry,
115333
+ axiom: !!renv.connectors.axiom,
114895
115334
  redis: !!renv.connectors.redis?.url,
114896
115335
  queue: !!ctx.queue
114897
115336
  }
@@ -117828,6 +118267,8 @@ function outcomeLabelToJSON(l2) {
117828
118267
  at: l2.at instanceof Date ? l2.at.toISOString() : l2.at
117829
118268
  };
117830
118269
  }
118270
+ var DEFAULT_ACCURACY_WINDOW_DAYS = 30;
118271
+ var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
117831
118272
  async function runMemoryAccuracy(opts) {
117832
118273
  try {
117833
118274
  let source;
@@ -117839,12 +118280,29 @@ async function runMemoryAccuracy(opts) {
117839
118280
  source = opts.source;
117840
118281
  }
117841
118282
  let since;
117842
- if (opts.days !== void 0) {
118283
+ let window2;
118284
+ if (opts.all) {
118285
+ window2 = "all time";
118286
+ } else if (opts.since !== void 0) {
118287
+ const parsed = new Date(opts.since);
118288
+ if (Number.isNaN(parsed.getTime())) {
118289
+ console.error(
118290
+ import_picocolors31.default.red("--since must be a parseable date (e.g. 2026-06-01 or an ISO timestamp).")
118291
+ );
118292
+ return 1;
118293
+ }
118294
+ since = parsed;
118295
+ window2 = `since ${parsed.toISOString().slice(0, 10)}`;
118296
+ } else if (opts.days !== void 0) {
117843
118297
  if (!Number.isFinite(opts.days) || opts.days <= 0) {
117844
118298
  console.error(import_picocolors31.default.red("--days must be a positive number."));
117845
118299
  return 1;
117846
118300
  }
117847
- since = new Date(Date.now() - opts.days * 24 * 60 * 60 * 1e3);
118301
+ since = new Date(Date.now() - opts.days * MS_PER_DAY2);
118302
+ window2 = `last ${opts.days} day(s)`;
118303
+ } else {
118304
+ since = new Date(Date.now() - DEFAULT_ACCURACY_WINDOW_DAYS * MS_PER_DAY2);
118305
+ window2 = `last ${DEFAULT_ACCURACY_WINDOW_DAYS} day(s) (default \u2014 pass --all for full history)`;
117848
118306
  }
117849
118307
  const config2 = await loadConfig(opts.config);
117850
118308
  const project = resolveProject(config2, opts.repo);
@@ -117865,6 +118323,7 @@ async function runMemoryAccuracy(opts) {
117865
118323
  project,
117866
118324
  source: source ?? null,
117867
118325
  since: since ? since.toISOString() : null,
118326
+ window: window2,
117868
118327
  summary,
117869
118328
  labels: labels.map(outcomeLabelToJSON)
117870
118329
  },
@@ -117874,11 +118333,11 @@ async function runMemoryAccuracy(opts) {
117874
118333
  );
117875
118334
  } else {
117876
118335
  const pct = (n4) => `${(n4 * 100).toFixed(0)}%`;
117877
- console.log(import_picocolors31.default.bold(`Investigation accuracy \u2014 ${project}`));
118336
+ console.log(import_picocolors31.default.bold(`Investigation accuracy \u2014 ${project}`) + import_picocolors31.default.dim(` \xB7 window: ${window2}`));
117878
118337
  if (summary.evaluated === 0) {
117879
118338
  console.log(
117880
118339
  import_picocolors31.default.dim(
117881
- "No outcome labels yet. Confirm an investigation (`horus memory confirm <id>`) or leave feedback (`horus feedback <id> --resolved yes|partly|no`) to start the eval set."
118340
+ `No outcome labels in this window (${window2}). Confirm an investigation (\`horus memory confirm <id>\`) or leave feedback (\`horus feedback <id> --resolved yes|partly|no\`) to add to the eval set` + (opts.all ? "." : ", or pass --all to include older labels.")
117882
118341
  )
117883
118342
  );
117884
118343
  } else {
@@ -119060,6 +119519,148 @@ async function checkboxSearch(opts) {
119060
119519
  render();
119061
119520
  });
119062
119521
  }
119522
+ async function selectSearch(opts) {
119523
+ const { message, choices } = opts;
119524
+ const pageSize = opts.pageSize ?? 10;
119525
+ const input = opts.input ?? process.stdin;
119526
+ const output = opts.output ?? process.stdout;
119527
+ if (!isInteractive(input)) {
119528
+ throw new Error("selectSearch requires an interactive TTY");
119529
+ }
119530
+ if (choices.length === 0) {
119531
+ throw new ExitPromptError("No choices to select from");
119532
+ }
119533
+ return new Promise((resolve12, reject) => {
119534
+ let filter = "";
119535
+ let cursor = 0;
119536
+ let filtered = choices;
119537
+ let visibleOffset = 0;
119538
+ let lastRenderLines = 0;
119539
+ const wasRaw = input.isRaw;
119540
+ function updateFiltered() {
119541
+ const f5 = filter.toLowerCase();
119542
+ filtered = f5 ? choices.filter((c4) => c4.toLowerCase().includes(f5)) : choices;
119543
+ cursor = Math.min(cursor, Math.max(0, filtered.length - 1));
119544
+ updateVisibleOffset();
119545
+ }
119546
+ function updateVisibleOffset() {
119547
+ if (cursor < visibleOffset) {
119548
+ visibleOffset = cursor;
119549
+ } else if (cursor >= visibleOffset + pageSize) {
119550
+ visibleOffset = cursor - pageSize + 1;
119551
+ }
119552
+ const maxOffset = Math.max(0, filtered.length - pageSize);
119553
+ visibleOffset = Math.max(0, Math.min(visibleOffset, maxOffset));
119554
+ }
119555
+ function clear() {
119556
+ output.write("\x1B[?25l");
119557
+ if (lastRenderLines > 0) {
119558
+ output.write(`\x1B[${lastRenderLines}A`);
119559
+ output.write("\x1B[0J");
119560
+ }
119561
+ }
119562
+ function render() {
119563
+ clear();
119564
+ const lines = [];
119565
+ lines.push(
119566
+ `? ${import_picocolors39.default.bold(message)} ${import_picocolors39.default.dim("(\u2191\u2193 navigate \u2022 enter confirm \u2022 type filter)")}`
119567
+ );
119568
+ const visible = filtered.slice(visibleOffset, visibleOffset + pageSize);
119569
+ for (let i3 = 0; i3 < visible.length; i3++) {
119570
+ const idx = visibleOffset + i3;
119571
+ const choice = visible[i3];
119572
+ const isCursor = idx === cursor;
119573
+ const pointer = isCursor ? import_picocolors39.default.cyan("\u276F") : " ";
119574
+ const label = isCursor ? import_picocolors39.default.cyan(choice) : choice;
119575
+ lines.push(` ${pointer} ${label}`);
119576
+ }
119577
+ if (filtered.length === 0) {
119578
+ lines.push(import_picocolors39.default.dim(" No matches"));
119579
+ }
119580
+ const statusParts = [`${filtered.length}/${choices.length} matches`];
119581
+ if (filter) statusParts.push(`filter: ${filter}`);
119582
+ lines.push(import_picocolors39.default.dim(` ${statusParts.join(" \xB7 ")}`));
119583
+ output.write(lines.join("\n"));
119584
+ lastRenderLines = lines.length;
119585
+ }
119586
+ function finish(value) {
119587
+ cleanup();
119588
+ output.write(`
119589
+ ? ${import_picocolors39.default.bold(message)} ${value}
119590
+ `);
119591
+ resolve12(value);
119592
+ }
119593
+ function cancel() {
119594
+ cleanup();
119595
+ reject(new ExitPromptError());
119596
+ }
119597
+ function cleanup() {
119598
+ input.setRawMode(wasRaw);
119599
+ input.pause();
119600
+ input.removeListener("data", onData);
119601
+ output.write("\x1B[?25h");
119602
+ }
119603
+ function onData(chunk) {
119604
+ const s5 = chunk.toString();
119605
+ if (s5 === "") {
119606
+ cancel();
119607
+ return;
119608
+ }
119609
+ if (s5 === "\x1B") {
119610
+ if (filter) {
119611
+ filter = "";
119612
+ cursor = 0;
119613
+ updateFiltered();
119614
+ } else {
119615
+ cancel();
119616
+ }
119617
+ render();
119618
+ return;
119619
+ }
119620
+ if (s5 === "\r" || s5 === "\n") {
119621
+ const choice = filtered[cursor];
119622
+ if (choice !== void 0) finish(choice);
119623
+ return;
119624
+ }
119625
+ if (s5 === "\x7F" || s5 === "\b") {
119626
+ if (filter.length > 0) {
119627
+ filter = filter.slice(0, -1);
119628
+ cursor = 0;
119629
+ updateFiltered();
119630
+ }
119631
+ render();
119632
+ return;
119633
+ }
119634
+ if (s5 === "\x1B[A") {
119635
+ if (cursor > 0) {
119636
+ cursor -= 1;
119637
+ updateVisibleOffset();
119638
+ }
119639
+ render();
119640
+ return;
119641
+ }
119642
+ if (s5 === "\x1B[B") {
119643
+ if (cursor < filtered.length - 1) {
119644
+ cursor += 1;
119645
+ updateVisibleOffset();
119646
+ }
119647
+ render();
119648
+ return;
119649
+ }
119650
+ if (s5.length === 1 && s5.charCodeAt(0) >= 32 && s5.charCodeAt(0) <= 126) {
119651
+ filter += s5;
119652
+ cursor = 0;
119653
+ updateFiltered();
119654
+ render();
119655
+ }
119656
+ }
119657
+ input.setRawMode(true);
119658
+ input.resume();
119659
+ input.on("data", onData);
119660
+ updateFiltered();
119661
+ render();
119662
+ });
119663
+ }
119063
119664
 
119064
119665
  // ../../packages/cli/src/commands/connect-ai.ts
119065
119666
  init_cjs_shims();
@@ -119197,7 +119798,7 @@ function parseDbSpec(spec) {
119197
119798
  }
119198
119799
  return { db, roles };
119199
119800
  }
119200
- var SUPPORTED = ["elasticsearch", "mongodb", "postgres", "sentry", "grafana", "redis"];
119801
+ var SUPPORTED = ["elasticsearch", "mongodb", "postgres", "sentry", "axiom", "grafana", "redis"];
119201
119802
  async function runConnect(type, opts) {
119202
119803
  if (type === "ai") {
119203
119804
  return runConnectAi({
@@ -119234,6 +119835,9 @@ async function runConnect(type, opts) {
119234
119835
  case "sentry":
119235
119836
  patch = buildSentryPatch(filled);
119236
119837
  break;
119838
+ case "axiom":
119839
+ patch = buildAxiomPatch(filled);
119840
+ break;
119237
119841
  case "grafana":
119238
119842
  patch = buildGrafanaPatch(filled);
119239
119843
  break;
@@ -119255,7 +119859,7 @@ ${import_picocolors41.default.red(`\u2717 Could not reach ${connectorType}:`)} $
119255
119859
  ${import_picocolors41.default.green("\u2713")} ${connectorType} reachable ${import_picocolors41.default.dim(`(${probeResult.detail})`)}`
119256
119860
  );
119257
119861
  }
119258
- const hasLiteralCredentials = filled.url !== void 0 || filled.password !== void 0 || filled.username !== void 0 || filled.authToken !== void 0;
119862
+ const hasLiteralCredentials = filled.url !== void 0 || filled.password !== void 0 || filled.username !== void 0 || filled.authToken !== void 0 || filled.token !== void 0;
119259
119863
  if (hasLiteralCredentials) {
119260
119864
  if (isGitTracked(configPath, root)) {
119261
119865
  console.error(
@@ -119332,6 +119936,35 @@ ${import_picocolors41.default.bold(`Connect ${type}`)} ${import_picocolors41.def
119332
119936
  filled.authToken = filled.authToken ?? (await askPassword("Auth token") || void 0);
119333
119937
  filled.url = filled.url ?? (await ask("Base URL (self-hosted)", "https://sentry.io", false) || void 0);
119334
119938
  break;
119939
+ case "axiom": {
119940
+ filled.token = filled.token ?? (await askPassword("Axiom API token") || void 0);
119941
+ if (filled.url === void 0) {
119942
+ filled.url = await askAxiomRegion();
119943
+ }
119944
+ if (filled.dataset === void 0) {
119945
+ const baseUrl = filled.url ?? "https://api.axiom.co";
119946
+ let names = [];
119947
+ if (filled.token) {
119948
+ const client = new AxiomClient({ token: filled.token, dataset: "", baseUrl });
119949
+ names = (await client.listDatasets()).map((d3) => d3.name).filter(Boolean);
119950
+ }
119951
+ if (names.length > 0) {
119952
+ const picked = await askDatasetSelection(names);
119953
+ if (picked) filled.dataset = picked;
119954
+ }
119955
+ if (filled.dataset === void 0) {
119956
+ if (names.length === 0) {
119957
+ console.log(
119958
+ import_picocolors41.default.yellow(
119959
+ " Couldn't list datasets (check token / network / region) \u2014 enter the name manually."
119960
+ )
119961
+ );
119962
+ }
119963
+ filled.dataset = await ask("Dataset name", "logs");
119964
+ }
119965
+ }
119966
+ break;
119967
+ }
119335
119968
  case "grafana":
119336
119969
  filled.url = filled.url ?? await ask("URL", "https://grafana.example.com");
119337
119970
  filled.username = filled.username ?? (await ask("Username", "", false) || void 0);
@@ -119389,6 +120022,8 @@ function missingRequired(type, opts) {
119389
120022
  return !opts.url;
119390
120023
  case "sentry":
119391
120024
  return !opts.org || !opts.project || !opts.authToken;
120025
+ case "axiom":
120026
+ return !opts.dataset || !opts.token;
119392
120027
  case "grafana":
119393
120028
  return !opts.url;
119394
120029
  case "redis":
@@ -119487,6 +120122,13 @@ function buildSentryPatch(opts) {
119487
120122
  if (opts.url && opts.url.replace(/\/$/, "") !== "https://sentry.io") patch["url"] = opts.url;
119488
120123
  return patch;
119489
120124
  }
120125
+ function buildAxiomPatch(opts) {
120126
+ if (!opts.dataset) throw new Error("Dataset is required for axiom");
120127
+ const patch = { dataset: opts.dataset };
120128
+ if (opts.token) patch["token"] = opts.token;
120129
+ if (opts.url && opts.url.replace(/\/$/, "") !== "https://api.axiom.co") patch["url"] = opts.url;
120130
+ return patch;
120131
+ }
119490
120132
  function buildGrafanaPatch(opts) {
119491
120133
  const patch = {};
119492
120134
  if (opts.url) patch["url"] = opts.url;
@@ -119622,6 +120264,17 @@ async function probe(type, opts) {
119622
120264
  });
119623
120265
  return client.health();
119624
120266
  }
120267
+ case "axiom": {
120268
+ if (!opts.token || !opts.dataset) {
120269
+ return { ok: true, detail: "skipped (missing dataset/token)" };
120270
+ }
120271
+ const client = new AxiomClient({
120272
+ token: opts.token,
120273
+ dataset: opts.dataset,
120274
+ ...opts.url ? { baseUrl: opts.url } : {}
120275
+ });
120276
+ return client.health();
120277
+ }
119625
120278
  case "grafana": {
119626
120279
  if (!opts.url) return { ok: true, detail: "skipped (no URL)" };
119627
120280
  const headers = { "Content-Type": "application/json" };
@@ -119673,8 +120326,11 @@ function printSummary(type, opts) {
119673
120326
  if (opts.collections) lines.push(` collections: ${opts.collections}`);
119674
120327
  if (opts.org) lines.push(` org: ${opts.org}`);
119675
120328
  if (opts.project) lines.push(` project: ${opts.project}`);
120329
+ if (opts.dataset) lines.push(` dataset: ${opts.dataset}`);
119676
120330
  if (opts.authToken)
119677
120331
  lines.push(` auth-token: ${"\u2022".repeat(Math.min(opts.authToken.length, 8))}`);
120332
+ if (opts.token)
120333
+ lines.push(` token: ${"\u2022".repeat(Math.min(opts.token.length, 8))}`);
119678
120334
  if (opts.dashboardUids && opts.dashboardUids.length > 0) {
119679
120335
  lines.push(` dashboards: ${opts.dashboardUids.join(", ")}`);
119680
120336
  } else if (opts.dashboard) {
@@ -119785,6 +120441,63 @@ async function askDashboardSelection(dashboards) {
119785
120441
  }
119786
120442
  return [];
119787
120443
  }
120444
+ async function askAxiomRegion() {
120445
+ const US = "US \u2014 api.axiom.co";
120446
+ const EU = "EU \u2014 api.eu.axiom.co";
120447
+ const CUSTOM = "Custom URL\u2026";
120448
+ const US_URL = "https://api.axiom.co";
120449
+ const EU_URL = "https://api.eu.axiom.co";
120450
+ let choice = US;
120451
+ if (isInteractive()) {
120452
+ try {
120453
+ choice = await selectSearch({
120454
+ message: "Axiom server / region",
120455
+ choices: [US, EU, CUSTOM],
120456
+ pageSize: 3
120457
+ });
120458
+ } catch (err2) {
120459
+ if (err2 instanceof ExitPromptError) throw err2;
120460
+ return US_URL;
120461
+ }
120462
+ } else {
120463
+ return US_URL;
120464
+ }
120465
+ if (choice === EU) return EU_URL;
120466
+ if (choice === CUSTOM) {
120467
+ return await ask("Custom Axiom base URL", US_URL) || US_URL;
120468
+ }
120469
+ return US_URL;
120470
+ }
120471
+ async function askDatasetSelection(names) {
120472
+ if (names.length === 0) return void 0;
120473
+ if (isInteractive()) {
120474
+ try {
120475
+ return await selectSearch({
120476
+ message: "Select an Axiom dataset",
120477
+ choices: names,
120478
+ pageSize: 12
120479
+ });
120480
+ } catch (err2) {
120481
+ if (err2 instanceof ExitPromptError) throw err2;
120482
+ }
120483
+ }
120484
+ const MAX_DISPLAY = 25;
120485
+ const shown = names.slice(0, MAX_DISPLAY);
120486
+ console.log("\n Available Axiom datasets:");
120487
+ shown.forEach((name2, i3) => {
120488
+ console.log(` ${import_picocolors41.default.dim(`[${i3 + 1}]`)} ${name2}`);
120489
+ });
120490
+ if (names.length > MAX_DISPLAY) {
120491
+ console.log(import_picocolors41.default.dim(` \u2026 and ${names.length - MAX_DISPLAY} more`));
120492
+ }
120493
+ const input = (await ask(" Select a dataset (e.g. 1 or Enter to type a name manually)", "", false)).trim();
120494
+ if (!input) return void 0;
120495
+ if (/^\d+$/.test(input)) {
120496
+ const idx = parseInt(input, 10) - 1;
120497
+ if (idx >= 0 && idx < shown.length) return shown[idx];
120498
+ }
120499
+ return input;
120500
+ }
119788
120501
  function redisUrlHasPassword(raw) {
119789
120502
  if (!raw) return false;
119790
120503
  try {
@@ -120879,6 +121592,7 @@ async function runReadiness(opts) {
120879
121592
  let anyMongo = false;
120880
121593
  let anyPostgres = false;
120881
121594
  let anySentry = false;
121595
+ let anyAxiom = false;
120882
121596
  let anyRedis = false;
120883
121597
  for (const project of globalConfig2.projects) {
120884
121598
  for (const repo of project.repositories) {
@@ -120891,6 +121605,7 @@ async function runReadiness(opts) {
120891
121605
  if (c4.mongodb) anyMongo = true;
120892
121606
  if (c4.postgres) anyPostgres = true;
120893
121607
  if (c4.sentry) anySentry = true;
121608
+ if (c4.axiom) anyAxiom = true;
120894
121609
  if (c4.redis) anyRedis = true;
120895
121610
  }
120896
121611
  }
@@ -120948,6 +121663,15 @@ async function runReadiness(opts) {
120948
121663
  next: "run `horus connect sentry`"
120949
121664
  }
120950
121665
  );
121666
+ checks.push(
121667
+ anyAxiom ? { label: "Axiom", status: "pass", blocking: false, detail: "configured" } : {
121668
+ label: "Axiom",
121669
+ status: "warn",
121670
+ blocking: false,
121671
+ detail: "not configured \u2014 no runtime log evidence",
121672
+ next: "run `horus connect axiom`"
121673
+ }
121674
+ );
120951
121675
  checks.push(
120952
121676
  anyRedis ? { label: "Redis", status: "pass", blocking: false, detail: "configured" } : {
120953
121677
  label: "Redis",
@@ -122675,8 +123399,8 @@ Examples:
122675
123399
  process.exitCode = await runReadiness({ config: opts.config, ai: opts.ai, aiModel: opts.aiModel });
122676
123400
  });
122677
123401
  program2.command("connect <type>").description(
122678
- "Add or update a connector (elasticsearch / mongodb / postgres / sentry / grafana / redis / ai) in .horus/config.json"
122679
- ).option("--env <name>", "target environment (default: first environment in config)").option("--provider <name>", "AI provider for `connect ai` (anthropic / claude / codex / gemini)").option("--api-key <key>", "Anthropic API key for `connect ai`").option("--model <id>", "default model for `connect ai`").option("--url <url>", "connector URL or connection string (Redis with auth: redis://:password@host:6379)").option("--username <user>", "username (elasticsearch / grafana)").option("--password <pass>", "password (elasticsearch / grafana; for Redis embed in --url)").option("--index-pattern <pattern>", "Elasticsearch index pattern (required for elasticsearch)").option("--service <name>", "service name scope for log queries").option("--database <name>", "database name (required for mongodb)").option("--collections <list>", "comma-separated collection allowlist (mongodb)").option("--schema <name>", "schema to introspect (postgres; default public)").option("--tables <list>", "comma-separated table allowlist (postgres)").option("--auth-token <token>", "Sentry API auth token (required for sentry)").option("--org <slug>", "Sentry org slug (required for sentry)").option("--project <slug>", "Sentry project slug (required for sentry)").option("--dashboard <uid>", "default dashboard uid (grafana)").option(
123402
+ "Add or update a connector (elasticsearch / mongodb / postgres / sentry / axiom / grafana / redis / ai) in .horus/config.json"
123403
+ ).option("--env <name>", "target environment (default: first environment in config)").option("--provider <name>", "AI provider for `connect ai` (anthropic / claude / codex / gemini)").option("--api-key <key>", "Anthropic API key for `connect ai`").option("--model <id>", "default model for `connect ai`").option("--url <url>", "connector URL or connection string (Redis with auth: redis://:password@host:6379)").option("--username <user>", "username (elasticsearch / grafana)").option("--password <pass>", "password (elasticsearch / grafana; for Redis embed in --url)").option("--index-pattern <pattern>", "Elasticsearch index pattern (required for elasticsearch)").option("--service <name>", "service name scope for log queries").option("--database <name>", "database name (required for mongodb)").option("--collections <list>", "comma-separated collection allowlist (mongodb)").option("--schema <name>", "schema to introspect (postgres; default public)").option("--tables <list>", "comma-separated table allowlist (postgres)").option("--auth-token <token>", "Sentry API auth token (required for sentry)").option("--org <slug>", "Sentry org slug (required for sentry)").option("--project <slug>", "Sentry project slug (required for sentry)").option("--token <token>", "Axiom API token (required for axiom)").option("--dataset <name>", "Axiom dataset to query (required for axiom)").option("--dashboard <uid>", "default dashboard uid (grafana)").option(
122680
123404
  "--db <spec>",
122681
123405
  "redis logical DB as db:role1,role2 (e.g. 0:cache,state or 1:bullmq,queues); repeatable",
122682
123406
  (val, acc) => {
@@ -122700,6 +123424,8 @@ Examples:
122700
123424
  authToken: opts.authToken,
122701
123425
  org: opts.org,
122702
123426
  project: opts.project,
123427
+ token: opts.token,
123428
+ dataset: opts.dataset,
122703
123429
  dashboard: opts.dashboard,
122704
123430
  db: opts.db,
122705
123431
  bullmqPrefix: opts.bullmqPrefix,
@@ -122814,7 +123540,7 @@ Examples:
122814
123540
  process.exitCode = await runMemoryDetect(opts);
122815
123541
  }
122816
123542
  );
122817
- memory.command("accuracy").description("Report Horus's measured hit-rate from the converged outcome-label eval set").option("-c, --config <path>", "path to horus.config.ts").option("--repo <name>", "project/repository to scope to (default: inferred from cwd)").option("--source <source>", "filter to one signal source: feedback | confirm").option("--days <n>", "only count labels from the last N days", (v4) => Number(v4)).option("--limit <n>", "max labels to scan", (v4) => Number(v4)).option("--json", "output JSON").action(
123543
+ memory.command("accuracy").description("Report Horus's measured hit-rate from the converged outcome-label eval set").option("-c, --config <path>", "path to horus.config.ts").option("--repo <name>", "project/repository to scope to (default: inferred from cwd)").option("--source <source>", "filter to one signal source: feedback | confirm").option("--since <date>", "only count labels on/after this date (e.g. 2026-06-01)").option("--days <n>", "only count labels from the last N days", (v4) => Number(v4)).option("--all", "count the full retained history (disable the default recent window)").option("--limit <n>", "max labels to scan", (v4) => Number(v4)).option("--json", "output JSON").action(
122818
123544
  async (opts) => {
122819
123545
  process.exitCode = await runMemoryAccuracy(opts);
122820
123546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {