@tangle-network/traces 0.5.0 → 0.7.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.
package/README.md CHANGED
@@ -18,6 +18,7 @@ It reads the transcripts your harness leaves on disk, reconstructs the run as sp
18
18
  - [Supported harnesses](#supported-harnesses)
19
19
  - [CLI reference](#cli-reference)
20
20
  - [Upload to the Intelligence Platform](#upload-to-the-intelligence-platform)
21
+ - [External engines (bring your own)](#external-engines-bring-your-own)
21
22
  - [Library (SDK)](#library-sdk)
22
23
  - [Examples](#examples)
23
24
  - [Develop](#develop)
@@ -42,7 +43,7 @@ That's the command in the demo above. The **deterministic pass** — stuck loops
42
43
 
43
44
  Add `--llm` for the **agentic analysts** (failure-mode / knowledge-gap / knowledge-poisoning / improvement); they call OpenAI and respect `--budget <usd>`.
44
45
 
45
- Every run also writes an **OTLP-JSONL artifact** that feeds [HALO](https://github.com/context-labs/halo) (`halo spans.jsonl -p "diagnose"`) — analysis is never locked to one engine.
46
+ Every run also writes an **OTLP-JSONL artifact**, and you can run external engines like [HALO](https://github.com/context-labs/halo) over it with `--analyzer halo` (traces converts our spans to the canonical OpenInference shape HALO needs) — see [External engines](#external-engines-bring-your-own). Analysis is never locked to one engine.
46
47
 
47
48
  ## What it finds
48
49
 
@@ -129,6 +130,31 @@ It does **not** catch free-form PII — names, postal addresses, phone numbers i
129
130
 
130
131
  Always `--dry-run` first to see exactly what would be sent.
131
132
 
133
+ ## External engines (bring your own)
134
+
135
+ `traces` hosts analysis engines and PII scrubbers it does **not** bundle — you install the tool, `traces` drives it over a thin command adapter. Same pattern for any future engine or model.
136
+
137
+ **Analyzers** run over the emitted OTLP artifact as peers to the built-in analysts:
138
+
139
+ ```bash
140
+ traces analyze --last 1 --analyzer halo # run HALO too
141
+ traces analyze --last 1 --analyzer halo --analyzer-prompt "find token waste"
142
+ traces analyze --last 1 --analyzer halo --analyzer my-engine # repeatable
143
+ ```
144
+
145
+ HALO needs canonical OpenInference, so `--analyzer halo` converts our spans automatically (`toCanonicalOpenInference`). HALO runs its *own* LLM (OpenAI client — set `OPENAI_BASE_URL` / `OPENAI_API_KEY`, or use HALO's provider); `--model` is forwarded to it. traces supplies the trace and drives the CLI; it doesn't pay for or configure HALO's model.
146
+
147
+ **Redactors** scrub prompt/response prose with an external PII model (catching names/addresses the regex pass can't), running *after* the built-in redaction:
148
+
149
+ ```bash
150
+ # the command reads a JSON array of strings on stdin, writes the scrubbed array on stdout
151
+ traces upload --since 24h --dry-run --redactor "my-pii-scrubber"
152
+ ```
153
+
154
+ In the SDK these are the `ExternalAnalyzer` and `Redactor` interfaces (`haloAnalyzer`, `commandAnalyzer`, `commandRedactor`, `applyRedactor`, `runExternalAnalyzers`). See [`examples/external-engines.ts`](./examples/external-engines.ts).
155
+
156
+ > For the built-in agentic analysts (`--llm`), set `OPENAI_API_KEY` — or point at any OpenAI-compatible gateway with `OPENAI_BASE_URL` (e.g. an internal router) to use a non-OpenAI key.
157
+
132
158
  ## Library (SDK)
133
159
 
134
160
  The CLI is a thin consumer of these exports.
@@ -143,6 +169,7 @@ The CLI is a thin consumer of these exports.
143
169
  | `planUpload` / `executeUpload` | `(…, { backend? }) → …` | redact + dedup + send to any sink |
144
170
  | `selectAdapters` / `listAdapters` / `resolveAdapter` | adapter selection + the harness registry |
145
171
  | `HarnessTraceAdapter` | interface (`locate` + `parse`) | implement to add a harness |
172
+ | `ExternalAnalyzer` / `Redactor` | `haloAnalyzer` / `commandAnalyzer` / `commandRedactor` | drive engines/models you install (not bundled) |
146
173
 
147
174
  ```ts
148
175
  import { watchSessions, analyzeSpans, AnalystRegistry, makeFinding } from '@tangle-network/traces'
@@ -172,6 +199,7 @@ Runnable, in [`examples/`](./examples):
172
199
  | [`custom-analyst.ts`](./examples/custom-analyst.ts) | register and run your own analyst |
173
200
  | [`custom-backend.ts`](./examples/custom-backend.ts) | redact + dedup + upload to your own sink |
174
201
  | [`register-harness.ts`](./examples/register-harness.ts) | add a new harness by implementing `HarnessTraceAdapter` |
202
+ | [`external-engines.ts`](./examples/external-engines.ts) | drive HALO + an external PII scrubber you install yourself |
175
203
 
176
204
  ## Develop
177
205
 
@@ -31,11 +31,11 @@ function serializeSpans(spans) {
31
31
  `;
32
32
  }
33
33
  async function writeOtlpFile(spans, outPath) {
34
- const { mkdtemp, writeFile: writeFile2 } = await import("fs/promises");
34
+ const { mkdtemp, writeFile: writeFile3 } = await import("fs/promises");
35
35
  const { tmpdir } = await import("os");
36
36
  const { join: join13 } = await import("path");
37
37
  const path = outPath ?? join13(await mkdtemp(join13(tmpdir(), "traces-")), "spans.otlp.jsonl");
38
- await writeFile2(path, serializeSpans(spans), "utf8");
38
+ await writeFile3(path, serializeSpans(spans), "utf8");
39
39
  return path;
40
40
  }
41
41
 
@@ -70,6 +70,151 @@ async function analyzeSpans(spans, opts = {}) {
70
70
  return { otlpPath, result };
71
71
  }
72
72
 
73
+ // src/external.ts
74
+ import { spawn } from "child_process";
75
+ import { readFile, writeFile } from "fs/promises";
76
+ function runCommand(command, args = [], opts = {}) {
77
+ const timeoutMs = opts.timeoutMs ?? 12e4;
78
+ const maxBuffer = opts.maxBuffer ?? 32 * 1024 * 1024;
79
+ return new Promise((resolve, reject) => {
80
+ let child;
81
+ try {
82
+ child = spawn(command, [...args], { signal: opts.signal });
83
+ } catch (err2) {
84
+ reject(err2 instanceof Error ? err2 : new Error(String(err2)));
85
+ return;
86
+ }
87
+ let out = "";
88
+ let err = "";
89
+ let killed = false;
90
+ const timer = setTimeout(() => {
91
+ killed = true;
92
+ child.kill("SIGKILL");
93
+ reject(new Error(`${command}: timed out after ${timeoutMs}ms`));
94
+ }, timeoutMs);
95
+ const cap = (add, buf) => {
96
+ const next = buf + add;
97
+ if (next.length > maxBuffer) {
98
+ killed = true;
99
+ child.kill("SIGKILL");
100
+ clearTimeout(timer);
101
+ reject(new Error(`${command}: output exceeded ${maxBuffer} bytes`));
102
+ }
103
+ return next;
104
+ };
105
+ child.stdout?.on("data", (d) => {
106
+ out = cap(String(d), out);
107
+ });
108
+ child.stderr?.on("data", (d) => {
109
+ err = cap(String(d), err);
110
+ });
111
+ child.on("error", (e) => {
112
+ clearTimeout(timer);
113
+ if (!killed) reject(e);
114
+ });
115
+ child.on("close", (code) => {
116
+ clearTimeout(timer);
117
+ if (!killed) resolve({ stdout: out, stderr: err, code });
118
+ });
119
+ if (opts.input !== void 0) {
120
+ child.stdin?.on("error", () => {
121
+ });
122
+ child.stdin?.end(opts.input);
123
+ }
124
+ });
125
+ }
126
+ function commandAnalyzer(spec) {
127
+ return {
128
+ name: spec.name,
129
+ async analyze(otlpPath, opts = {}) {
130
+ try {
131
+ const res = await runCommand(spec.command, spec.args(otlpPath, opts.prompt), {
132
+ signal: opts.signal,
133
+ timeoutMs: spec.timeoutMs
134
+ });
135
+ if (res.code !== 0) {
136
+ return { analyzer: spec.name, ok: false, output: res.stdout, error: res.stderr.trim() || `exit ${res.code}` };
137
+ }
138
+ return { analyzer: spec.name, ok: true, output: res.stdout.trim(), findings: spec.parse?.(res.stdout) };
139
+ } catch (e) {
140
+ return { analyzer: spec.name, ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
141
+ }
142
+ }
143
+ };
144
+ }
145
+ function toCanonicalOpenInference(otlpJsonl, opts = {}) {
146
+ return otlpJsonl.split("\n").filter((l) => l.trim()).map((line) => {
147
+ const s = JSON.parse(line);
148
+ const a = s.attributes ?? {};
149
+ const resourceAttrs = { "service.name": a["service.name"] ?? opts.service ?? "traces" };
150
+ if (a["agent.name"]) resourceAttrs["agent.name"] = a["agent.name"];
151
+ return JSON.stringify({
152
+ trace_id: s.trace_id,
153
+ span_id: s.span_id,
154
+ parent_span_id: s.parent_span_id ?? "",
155
+ trace_state: "",
156
+ name: s.name,
157
+ kind: a["openinference.span.kind"] ?? "CHAIN",
158
+ start_time: s.start_time,
159
+ end_time: s.end_time,
160
+ status: { code: s.status?.code ?? "OK", message: s.status?.message ?? "" },
161
+ resource: { attributes: resourceAttrs },
162
+ scope: { name: opts.scopeName ?? "tangle-traces", version: "" },
163
+ attributes: a
164
+ });
165
+ }).join("\n");
166
+ }
167
+ function haloAnalyzer(opts = {}) {
168
+ const command = opts.command ?? "halo";
169
+ const defaultPrompt = opts.defaultPrompt ?? "diagnose";
170
+ return {
171
+ name: "halo",
172
+ async analyze(otlpPath, o = {}) {
173
+ try {
174
+ const canonical = toCanonicalOpenInference(await readFile(otlpPath, "utf8"));
175
+ const canonPath = `${otlpPath}.halo.jsonl`;
176
+ await writeFile(canonPath, canonical);
177
+ const args = [canonPath, "-p", o.prompt ?? defaultPrompt];
178
+ if (opts.model) args.push("-m", opts.model);
179
+ const res = await runCommand(command, args, { signal: o.signal, timeoutMs: opts.timeoutMs });
180
+ if (res.code !== 0) {
181
+ return { analyzer: "halo", ok: false, output: res.stdout, error: res.stderr.trim() || `exit ${res.code}` };
182
+ }
183
+ return { analyzer: "halo", ok: true, output: res.stdout.trim() };
184
+ } catch (e) {
185
+ return { analyzer: "halo", ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
186
+ }
187
+ }
188
+ };
189
+ }
190
+ function runExternalAnalyzers(otlpPath, analyzers, opts = {}) {
191
+ return Promise.all(analyzers.map((a) => a.analyze(otlpPath, opts)));
192
+ }
193
+ function commandRedactor(spec) {
194
+ const name = spec.name ?? spec.command;
195
+ return {
196
+ name,
197
+ async redactText(texts) {
198
+ if (texts.length === 0) return [];
199
+ const res = await runCommand(spec.command, spec.args ?? [], {
200
+ input: JSON.stringify(texts),
201
+ timeoutMs: spec.timeoutMs
202
+ });
203
+ if (res.code !== 0) throw new Error(`redactor ${name}: exit ${res.code}: ${res.stderr.trim()}`);
204
+ let parsed;
205
+ try {
206
+ parsed = JSON.parse(res.stdout);
207
+ } catch {
208
+ throw new Error(`redactor ${name}: stdout was not JSON`);
209
+ }
210
+ if (!Array.isArray(parsed) || parsed.length !== texts.length) {
211
+ throw new Error(`redactor ${name}: expected a JSON array of ${texts.length} strings`);
212
+ }
213
+ return parsed.map(String);
214
+ }
215
+ };
216
+ }
217
+
73
218
  // src/time.ts
74
219
  function parseIsoToEpochMs(ts) {
75
220
  if (!ts) return 0;
@@ -162,7 +307,7 @@ function userPromptSpan(o) {
162
307
  }
163
308
 
164
309
  // src/adapters/amp.ts
165
- import { readdir, readFile, stat } from "fs/promises";
310
+ import { readdir, readFile as readFile2, stat } from "fs/promises";
166
311
  import { homedir } from "os";
167
312
  import { basename, join } from "path";
168
313
  var SERVICE = "amp";
@@ -204,7 +349,7 @@ var AmpAdapter = class {
204
349
  async parse(ref) {
205
350
  let thread;
206
351
  try {
207
- thread = JSON.parse(await readFile(ref.path, "utf8"));
352
+ thread = JSON.parse(await readFile2(ref.path, "utf8"));
208
353
  } catch {
209
354
  return [];
210
355
  }
@@ -288,7 +433,7 @@ var AmpAdapter = class {
288
433
  };
289
434
 
290
435
  // src/adapters/claude.ts
291
- import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "fs/promises";
436
+ import { readdir as readdir2, readFile as readFile3, stat as stat2 } from "fs/promises";
292
437
  import { homedir as homedir2 } from "os";
293
438
  import { basename as basename2, join as join2 } from "path";
294
439
  var SERVICE2 = "claude-code";
@@ -449,7 +594,7 @@ var ClaudeAdapter = class {
449
594
  return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
450
595
  }
451
596
  async parse(ref) {
452
- const raw = await readFile2(ref.path, "utf8");
597
+ const raw = await readFile3(ref.path, "utf8");
453
598
  const events = parseLines(raw);
454
599
  const traceId = events.find((e) => e.sessionId)?.sessionId ?? ref.sessionId;
455
600
  const rootId = `root:${traceId}`;
@@ -492,13 +637,13 @@ var ClaudeAdapter = class {
492
637
  const hash = basename2(f, ".jsonl");
493
638
  let meta = {};
494
639
  try {
495
- meta = JSON.parse(await readFile2(join2(subDir, `${hash}.meta.json`), "utf8"));
640
+ meta = JSON.parse(await readFile3(join2(subDir, `${hash}.meta.json`), "utf8"));
496
641
  } catch {
497
642
  }
498
643
  const parent = meta.toolUseId && main.toolSpanByUseId.get(meta.toolUseId)?.span_id || `root:${traceId}`;
499
644
  let raw;
500
645
  try {
501
- raw = await readFile2(join2(subDir, f), "utf8");
646
+ raw = await readFile3(join2(subDir, f), "utf8");
502
647
  } catch {
503
648
  continue;
504
649
  }
@@ -516,7 +661,7 @@ var ClaudeAdapter = class {
516
661
  };
517
662
 
518
663
  // src/adapters/codex.ts
519
- import { readdir as readdir3, readFile as readFile3, stat as stat3 } from "fs/promises";
664
+ import { readdir as readdir3, readFile as readFile4, stat as stat3 } from "fs/promises";
520
665
  import { homedir as homedir3 } from "os";
521
666
  import { basename as basename3, join as join3 } from "path";
522
667
  var SERVICE3 = "codex";
@@ -600,7 +745,7 @@ var CodexAdapter = class {
600
745
  continue;
601
746
  }
602
747
  if (opts.sinceMs && st.mtimeMs < opts.sinceMs) continue;
603
- const head = (await readFile3(path, "utf8").catch(() => "")).split("\n", 1)[0] ?? "";
748
+ const head = (await readFile4(path, "utf8").catch(() => "")).split("\n", 1)[0] ?? "";
604
749
  let cwd = null;
605
750
  let id = basename3(path).replace(/^rollout-[\dT-]+-/, "").replace(/\.jsonl$/, "");
606
751
  try {
@@ -615,7 +760,7 @@ var CodexAdapter = class {
615
760
  return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
616
761
  }
617
762
  async parse(ref) {
618
- const lines = parseLines2(await readFile3(ref.path, "utf8"));
763
+ const lines = parseLines2(await readFile4(ref.path, "utf8"));
619
764
  const meta = lines.find((l) => l.type === "session_meta");
620
765
  const traceId = meta?.payload?.id ?? ref.sessionId;
621
766
  const model = lines.find((l) => l.type === "turn_context")?.payload?.model ?? null;
@@ -731,7 +876,7 @@ var CodexAdapter = class {
731
876
  };
732
877
 
733
878
  // src/adapters/copilot.ts
734
- import { readdir as readdir4, readFile as readFile4, stat as stat4 } from "fs/promises";
879
+ import { readdir as readdir4, readFile as readFile5, stat as stat4 } from "fs/promises";
735
880
  import { homedir as homedir4 } from "os";
736
881
  import { join as join4 } from "path";
737
882
  var SERVICE4 = "github-copilot";
@@ -776,7 +921,7 @@ var CopilotAdapter = class {
776
921
  return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
777
922
  }
778
923
  async parse(ref) {
779
- const events = parseLines3(await readFile4(ref.path, "utf8"));
924
+ const events = parseLines3(await readFile5(ref.path, "utf8"));
780
925
  const traceId = ref.sessionId;
781
926
  const rootId = `root:${traceId}`;
782
927
  const spans = [
@@ -857,7 +1002,7 @@ var CopilotAdapter = class {
857
1002
  };
858
1003
 
859
1004
  // src/adapters/factory.ts
860
- import { readdir as readdir5, readFile as readFile5, stat as stat5 } from "fs/promises";
1005
+ import { readdir as readdir5, readFile as readFile6, stat as stat5 } from "fs/promises";
861
1006
  import { homedir as homedir5 } from "os";
862
1007
  import { basename as basename4, join as join5 } from "path";
863
1008
  var SERVICE5 = "factory";
@@ -918,13 +1063,13 @@ var FactoryAdapter = class {
918
1063
  return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
919
1064
  }
920
1065
  async parse(ref) {
921
- const lines = parseLines4(await readFile5(ref.path, "utf8"));
1066
+ const lines = parseLines4(await readFile6(ref.path, "utf8"));
922
1067
  const start = lines.find((l) => l.type === "session_start");
923
1068
  const traceId = start?.id ?? ref.sessionId;
924
1069
  const rootId = `root:${traceId}`;
925
1070
  let settings = {};
926
1071
  try {
927
- settings = JSON.parse(await readFile5(ref.path.replace(/\.jsonl$/, ".settings.json"), "utf8"));
1072
+ settings = JSON.parse(await readFile6(ref.path.replace(/\.jsonl$/, ".settings.json"), "utf8"));
928
1073
  } catch {
929
1074
  }
930
1075
  const spans = [
@@ -1018,7 +1163,7 @@ var FactoryAdapter = class {
1018
1163
  };
1019
1164
 
1020
1165
  // src/adapters/forge.ts
1021
- import { readdir as readdir6, readFile as readFile6, stat as stat6 } from "fs/promises";
1166
+ import { readdir as readdir6, readFile as readFile7, stat as stat6 } from "fs/promises";
1022
1167
  import { homedir as homedir6 } from "os";
1023
1168
  import { basename as basename5, join as join6 } from "path";
1024
1169
  var SERVICE6 = "forge";
@@ -1064,7 +1209,7 @@ var ForgeAdapter = class {
1064
1209
  async parse(ref) {
1065
1210
  let dump;
1066
1211
  try {
1067
- dump = JSON.parse(await readFile6(ref.path, "utf8"));
1212
+ dump = JSON.parse(await readFile7(ref.path, "utf8"));
1068
1213
  } catch {
1069
1214
  return [];
1070
1215
  }
@@ -1148,7 +1293,7 @@ var ForgeAdapter = class {
1148
1293
  };
1149
1294
 
1150
1295
  // src/adapters/gemini.ts
1151
- import { readdir as readdir7, readFile as readFile7, stat as stat7 } from "fs/promises";
1296
+ import { readdir as readdir7, readFile as readFile8, stat as stat7 } from "fs/promises";
1152
1297
  import { homedir as homedir7 } from "os";
1153
1298
  import { join as join7 } from "path";
1154
1299
  function iso(ts, fallback) {
@@ -1213,7 +1358,7 @@ var GeminiFamilyAdapter = class {
1213
1358
  async parse(ref) {
1214
1359
  let session;
1215
1360
  try {
1216
- session = JSON.parse(await readFile7(ref.path, "utf8"));
1361
+ session = JSON.parse(await readFile8(ref.path, "utf8"));
1217
1362
  } catch {
1218
1363
  return [];
1219
1364
  }
@@ -1307,7 +1452,7 @@ var GeminiAdapter = class extends GeminiFamilyAdapter {
1307
1452
  };
1308
1453
 
1309
1454
  // src/adapters/opencode.ts
1310
- import { readdir as readdir8, readFile as readFile8, stat as stat8 } from "fs/promises";
1455
+ import { readdir as readdir8, readFile as readFile9, stat as stat8 } from "fs/promises";
1311
1456
  import { homedir as homedir8 } from "os";
1312
1457
  import { join as join8 } from "path";
1313
1458
  var SERVICE7 = "opencode";
@@ -1362,7 +1507,7 @@ var OpencodeAdapter = class {
1362
1507
  const messages = [];
1363
1508
  for (const f of msgFiles) {
1364
1509
  try {
1365
- messages.push({ msg: JSON.parse(await readFile8(join8(ref.path, f), "utf8")), file: f });
1510
+ messages.push({ msg: JSON.parse(await readFile9(join8(ref.path, f), "utf8")), file: f });
1366
1511
  } catch {
1367
1512
  }
1368
1513
  }
@@ -1390,7 +1535,7 @@ var OpencodeAdapter = class {
1390
1535
  const partFiles = (await readdir8(pdir)).filter((f) => f.endsWith(".json"));
1391
1536
  for (const pf of partFiles) {
1392
1537
  try {
1393
- parts.push(JSON.parse(await readFile8(join8(pdir, pf), "utf8")));
1538
+ parts.push(JSON.parse(await readFile9(join8(pdir, pf), "utf8")));
1394
1539
  } catch {
1395
1540
  }
1396
1541
  }
@@ -1460,7 +1605,7 @@ var OpencodeAdapter = class {
1460
1605
  };
1461
1606
 
1462
1607
  // src/adapters/pi.ts
1463
- import { readdir as readdir9, readFile as readFile9, stat as stat9 } from "fs/promises";
1608
+ import { readdir as readdir9, readFile as readFile10, stat as stat9 } from "fs/promises";
1464
1609
  import { homedir as homedir9 } from "os";
1465
1610
  import { basename as basename6, join as join9 } from "path";
1466
1611
  var SERVICE8 = "pi";
@@ -1528,7 +1673,7 @@ var PiAdapter = class {
1528
1673
  return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
1529
1674
  }
1530
1675
  async parse(ref) {
1531
- const lines = parseLines5(await readFile9(ref.path, "utf8"));
1676
+ const lines = parseLines5(await readFile10(ref.path, "utf8"));
1532
1677
  const sessionLine = lines.find((l) => l.type === "session");
1533
1678
  const traceId = sessionLine?.id ?? ref.sessionId;
1534
1679
  const rootId = `root:${traceId}`;
@@ -1629,7 +1774,7 @@ var PiAdapter = class {
1629
1774
  };
1630
1775
 
1631
1776
  // src/adapters/qwen.ts
1632
- import { readdir as readdir10, readFile as readFile10, stat as stat10 } from "fs/promises";
1777
+ import { readdir as readdir10, readFile as readFile11, stat as stat10 } from "fs/promises";
1633
1778
  import { homedir as homedir10 } from "os";
1634
1779
  import { basename as basename7, join as join10 } from "path";
1635
1780
  var SERVICE9 = "qwen";
@@ -1688,7 +1833,7 @@ var QwenAdapter = class {
1688
1833
  return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
1689
1834
  }
1690
1835
  async parse(ref) {
1691
- const records = parseLines6(await readFile10(ref.path, "utf8"));
1836
+ const records = parseLines6(await readFile11(ref.path, "utf8"));
1692
1837
  const traceId = records.find((r) => r.sessionId)?.sessionId ?? ref.sessionId;
1693
1838
  const rootId = `root:${traceId}`;
1694
1839
  const spans = [
@@ -1988,10 +2133,32 @@ function redactSpans(spans, rules = TRACES_REDACTION_RULES) {
1988
2133
  });
1989
2134
  return { spans: out, report };
1990
2135
  }
2136
+ async function applyRedactor(spans, redactor) {
2137
+ const idx = [];
2138
+ const texts = [];
2139
+ spans.forEach((s, i) => {
2140
+ const c = s.attributes["content"];
2141
+ if (typeof c === "string" && c.length > 0) {
2142
+ idx.push(i);
2143
+ texts.push(c);
2144
+ }
2145
+ });
2146
+ const out = spans.map((s) => ({ ...s, attributes: { ...s.attributes } }));
2147
+ if (texts.length === 0) return { spans: out, changed: 0 };
2148
+ const scrubbed = await redactor.redactText(texts);
2149
+ let changed = 0;
2150
+ idx.forEach((spanI, k) => {
2151
+ if (scrubbed[k] !== texts[k]) {
2152
+ out[spanI].attributes["content"] = scrubbed[k];
2153
+ changed += 1;
2154
+ }
2155
+ });
2156
+ return { spans: out, changed };
2157
+ }
1991
2158
 
1992
2159
  // src/upload-state.ts
1993
2160
  import { createHash } from "crypto";
1994
- import { mkdir, readFile as readFile11, writeFile } from "fs/promises";
2161
+ import { mkdir, readFile as readFile12, writeFile as writeFile2 } from "fs/promises";
1995
2162
  import { homedir as homedir11 } from "os";
1996
2163
  import { dirname, join as join11 } from "path";
1997
2164
  function stateFile() {
@@ -2011,7 +2178,7 @@ function sessionHash(spans) {
2011
2178
  }
2012
2179
  async function loadState() {
2013
2180
  try {
2014
- return JSON.parse(await readFile11(stateFile(), "utf8"));
2181
+ return JSON.parse(await readFile12(stateFile(), "utf8"));
2015
2182
  } catch {
2016
2183
  return {};
2017
2184
  }
@@ -2019,7 +2186,7 @@ async function loadState() {
2019
2186
  async function saveState(state) {
2020
2187
  const f = stateFile();
2021
2188
  await mkdir(dirname(f), { recursive: true });
2022
- await writeFile(f, `${JSON.stringify(state, null, 2)}
2189
+ await writeFile2(f, `${JSON.stringify(state, null, 2)}
2023
2190
  `, "utf8");
2024
2191
  }
2025
2192
  function alreadyUploaded(state, harness, sessionId, hash) {
@@ -2027,7 +2194,7 @@ function alreadyUploaded(state, harness, sessionId, hash) {
2027
2194
  }
2028
2195
 
2029
2196
  // src/upload.ts
2030
- import { readFile as readFile12 } from "fs/promises";
2197
+ import { readFile as readFile13 } from "fs/promises";
2031
2198
  import { createRequire } from "module";
2032
2199
  import { hostname } from "os";
2033
2200
  import { basename as basename8, join as join12 } from "path";
@@ -2054,7 +2221,7 @@ async function planUpload(opts = {}) {
2054
2221
  async function gitBranch(cwd) {
2055
2222
  if (!cwd) return void 0;
2056
2223
  try {
2057
- const head = await readFile12(join12(cwd, ".git", "HEAD"), "utf8");
2224
+ const head = await readFile13(join12(cwd, ".git", "HEAD"), "utf8");
2058
2225
  const m = head.match(/^ref:\s*refs\/heads\/(.+)$/m);
2059
2226
  return m?.[1]?.trim();
2060
2227
  } catch {
@@ -2108,7 +2275,12 @@ function stripSpanContent(spans) {
2108
2275
  });
2109
2276
  }
2110
2277
  async function executeUpload(plan, opts = {}) {
2111
- const newItems = plan.items.filter((i) => i.isNew).map((i) => opts.stripContent ? { ...i, spans: stripSpanContent(i.spans) } : i);
2278
+ let newItems = plan.items.filter((i) => i.isNew);
2279
+ if (opts.redactor && !opts.stripContent) {
2280
+ const r = opts.redactor;
2281
+ newItems = await Promise.all(newItems.map(async (i) => ({ ...i, spans: (await applyRedactor(i.spans, r)).spans })));
2282
+ }
2283
+ if (opts.stripContent) newItems = newItems.map((i) => ({ ...i, spans: stripSpanContent(i.spans) }));
2112
2284
  const skipped = plan.items.length - newItems.length;
2113
2285
  const redactionCount = newItems.reduce((n, i) => n + i.redaction.redactionCount, 0);
2114
2286
  const uploadedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2161,6 +2333,12 @@ export {
2161
2333
  serializeSpans,
2162
2334
  writeOtlpFile,
2163
2335
  analyzeSpans,
2336
+ runCommand,
2337
+ commandAnalyzer,
2338
+ toCanonicalOpenInference,
2339
+ haloAnalyzer,
2340
+ runExternalAnalyzers,
2341
+ commandRedactor,
2164
2342
  parseIsoToEpochMs,
2165
2343
  parseSince,
2166
2344
  toRuntimeStore,
@@ -2190,6 +2368,7 @@ export {
2190
2368
  CODING_REDACTION_RULES,
2191
2369
  TRACES_REDACTION_RULES,
2192
2370
  redactSpans,
2371
+ applyRedactor,
2193
2372
  uploadKey,
2194
2373
  sessionHash,
2195
2374
  loadState,
package/dist/cli.js CHANGED
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  analyzeSpans,
4
+ commandAnalyzer,
5
+ commandRedactor,
4
6
  executeUpload,
7
+ haloAnalyzer,
5
8
  knownHarnesses,
6
9
  parseSince,
7
10
  planUpload,
8
11
  resolveAdapter,
12
+ runExternalAnalyzers,
9
13
  runPipelines,
10
14
  selectAdapters,
11
15
  watchSessions,
12
16
  writeOtlpFile
13
- } from "./chunk-HF2KGEBW.js";
17
+ } from "./chunk-Q56AP2EY.js";
14
18
 
15
19
  // src/cli.ts
16
20
  import { stat, writeFile } from "fs/promises";
@@ -68,7 +72,7 @@ function renderReport(result, meta) {
68
72
  }
69
73
  }
70
74
  lines.push("---");
71
- lines.push(`OTLP artifact: \`${meta.otlpPath}\` \u2014 also runnable with \`halo ${meta.otlpPath} -p "diagnose"\`.`);
75
+ lines.push(`OTLP artifact: \`${meta.otlpPath}\` \u2014 run external engines with \`traces analyze --analyzer halo\`.`);
72
76
  lines.push("");
73
77
  return lines.join("\n");
74
78
  }
@@ -106,7 +110,8 @@ function parseArgs(argv) {
106
110
  minLoop: 3,
107
111
  dryRun: false,
108
112
  yes: false,
109
- noContent: false
113
+ noContent: false,
114
+ analyzers: []
110
115
  };
111
116
  for (let i = 1; i < argv.length; i++) {
112
117
  const arg = argv[i];
@@ -143,6 +148,9 @@ function parseArgs(argv) {
143
148
  case "--budget":
144
149
  a.budget = Number(next());
145
150
  break;
151
+ case "--model":
152
+ a.model = next();
153
+ break;
146
154
  case "--interval":
147
155
  a.interval = Number(next());
148
156
  break;
@@ -158,6 +166,17 @@ function parseArgs(argv) {
158
166
  case "--no-content":
159
167
  a.noContent = true;
160
168
  break;
169
+ case "--analyzer": {
170
+ const v = next();
171
+ if (v) a.analyzers.push(v);
172
+ break;
173
+ }
174
+ case "--analyzer-prompt":
175
+ a.analyzerPrompt = next();
176
+ break;
177
+ case "--redactor":
178
+ a.redactorCmd = next();
179
+ break;
161
180
  case "--yes":
162
181
  case "-y":
163
182
  a.yes = true;
@@ -197,10 +216,13 @@ async function discover(args) {
197
216
  async function buildAxService() {
198
217
  const apiKey = process.env.OPENAI_API_KEY;
199
218
  if (!apiKey) {
200
- throw new Error("--llm needs OPENAI_API_KEY for the agentic analyst kinds (deterministic analysis needs no key)");
219
+ throw new Error(
220
+ "--llm needs OPENAI_API_KEY \u2014 an OpenAI key, or a router/gateway key with OPENAI_BASE_URL set to an OpenAI-compatible endpoint (e.g. the Tangle router). Deterministic analysis needs no key."
221
+ );
201
222
  }
223
+ const apiURL = process.env.OPENAI_BASE_URL || void 0;
202
224
  const { AxAI } = await import("@ax-llm/ax");
203
- return new AxAI({ name: "openai", apiKey });
225
+ return new AxAI({ name: "openai", apiKey, ...apiURL ? { apiURL } : {} });
204
226
  }
205
227
  async function cmdList(args) {
206
228
  const groups = await discover({ ...args, last: args.last || 20 });
@@ -251,14 +273,28 @@ async function cmdAnalyze(args) {
251
273
  const ai = args.llm ? await buildAxService() : void 0;
252
274
  const { otlpPath, result } = await analyzeSpans(spans, {
253
275
  ai,
276
+ model: args.model,
254
277
  budgetUsd: args.budget,
255
278
  otlpOutPath: args.otlp,
256
279
  log: (msg) => process.stderr.write(`${msg}
257
280
  `)
258
281
  });
259
282
  const pipelines = await runPipelines(spans, { minLoopOccurrences: args.minLoop });
260
- const report = `${renderReport(result, { harness, sessionCount, spanCount: spans.length, otlpPath })}
283
+ let report = `${renderReport(result, { harness, sessionCount, spanCount: spans.length, otlpPath })}
261
284
  ${renderPipelines(pipelines)}`;
285
+ if (args.analyzers.length > 0 && otlpPath) {
286
+ const engines = args.analyzers.map(
287
+ (spec) => spec === "halo" ? haloAnalyzer({ defaultPrompt: args.analyzerPrompt, model: args.model }) : commandAnalyzer({ name: spec, command: spec, args: (p, prompt) => prompt ? [p, prompt] : [p] })
288
+ );
289
+ const results = await runExternalAnalyzers(otlpPath, engines, { prompt: args.analyzerPrompt });
290
+ for (const r of results) {
291
+ report += `
292
+
293
+ ## ${r.analyzer} (external analyzer)
294
+
295
+ ${r.ok ? r.output || "(no output)" : `failed: ${r.error}`}`;
296
+ }
297
+ }
262
298
  if (args.out) {
263
299
  await writeFile(args.out, report, "utf8");
264
300
  console.log(`report \u2192 ${args.out} (${result.findings.length} findings, ${pipelines.stuckLoops.findings.length} loops, OTLP: ${otlpPath})`);
@@ -300,6 +336,8 @@ async function cmdUpload(args) {
300
336
  cwd: args.cwd,
301
337
  sinceMs
302
338
  });
339
+ const redactorParts = args.redactorCmd?.split(/\s+/).filter(Boolean) ?? [];
340
+ const redactor = redactorParts[0] ? commandRedactor({ command: redactorParts[0], args: redactorParts.slice(1) }) : void 0;
303
341
  const newItems = plan.items.filter((i) => i.isNew);
304
342
  const byRule = {};
305
343
  let totalRedactions = 0;
@@ -326,7 +364,7 @@ Window: since ${new Date(sinceMs).toISOString()}
326
364
  return;
327
365
  }
328
366
  if (args.dryRun) {
329
- const res2 = await executeUpload(plan, { dryRun: true, otlpOut: args.otlp, stripContent: args.noContent });
367
+ const res2 = await executeUpload(plan, { dryRun: true, otlpOut: args.otlp, stripContent: args.noContent, redactor });
330
368
  console.log(`dry run \u2014 ${newItems.length} session(s), ${totalRedactions} redaction(s). Redacted OTLP \u2192 ${res2.otlpPath}`);
331
369
  console.log("No upload performed. Set TANGLE_INGEST_URL / TANGLE_INGEST_API_KEY / TANGLE_TENANT_ID and drop --dry-run to send.");
332
370
  return;
@@ -339,7 +377,7 @@ Window: since ${new Date(sinceMs).toISOString()}
339
377
  }
340
378
  }
341
379
  const res = await executeUpload(plan, { log: (m) => w(`${m}
342
- `), stripContent: args.noContent });
380
+ `), stripContent: args.noContent, redactor });
343
381
  console.log(
344
382
  `Uploaded ${res.uploadedSessions} session(s), ${res.acceptedSpans} spans accepted, ${res.redactionCount} redaction(s); ${res.skippedSessions} already-uploaded skipped.`
345
383
  );
@@ -350,7 +388,7 @@ function usage() {
350
388
  Commands:
351
389
  list List discovered sessions
352
390
  analyze Run analyst suite + loop/waste pipelines, write a markdown report
353
- convert Emit OTLP-JSONL only (also feeds HALO)
391
+ convert Emit OTLP-JSONL only (HALO: use analyze --analyzer halo)
354
392
  watch Online observer: tail active sessions, notify on stuck loops (read-only)
355
393
  upload Redact + upload sessions in a time window to the Tangle Intelligence Platform
356
394
 
@@ -363,13 +401,17 @@ Options:
363
401
  --since <t> upload: window \u2014 30m / 2h / 7d or an ISO date (default 24h); analyze: ISO cutoff
364
402
  --out <path> Write report to a file
365
403
  --otlp <path> OTLP artifact path (also the dry-run upload preview path)
366
- --llm Enable agentic RLM analysts (needs OPENAI_API_KEY)
404
+ --llm Enable agentic RLM analysts (needs OPENAI_API_KEY / OPENAI_BASE_URL)
405
+ --model <id> --llm model id (e.g. a router model like glm-5.2); default is agent-eval's
367
406
  --budget <usd> USD cap for agentic analysts
407
+ --analyzer <cmd> analyze: also run an external engine over the OTLP (repeatable; "halo" or any command)
408
+ --analyzer-prompt <p> analyze: prompt passed to external analyzers (default: diagnose)
368
409
  --interval <s> watch: poll interval seconds (default 5)
369
410
  --window <m> watch: only sessions active in the last N minutes (default 30)
370
411
  --min-loop <n> Min identical repeated calls to flag a loop (default 3)
371
412
  --dry-run upload: redact + dedup + preview, write OTLP, but do NOT send
372
413
  --no-content upload: strip prompt/response text \u2014 send metadata only
414
+ --redactor <cmd> upload: external PII scrubber (JSON array stdin\u2192stdout) after the regex pass
373
415
  --yes, -y upload: skip the confirmation prompt
374
416
 
375
417
  Upload env: TANGLE_INGEST_URL (or TANGLE_ORCHESTRATOR_URL), TANGLE_INGEST_API_KEY (or TANGLE_API_KEY), TANGLE_TENANT_ID`);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ToolUseMetrics, InMemoryTraceStore } from '@tangle-network/agent-eval';
2
2
  import { StuckLoopReport } from '@tangle-network/agent-eval/pipelines';
3
3
  import { AxAIService } from '@ax-llm/ax';
4
- import { AnalystRegistry, buildDefaultAnalystRegistry } from '@tangle-network/agent-eval/analyst';
4
+ import { AnalystRegistry, buildDefaultAnalystRegistry, AnalystFinding } from '@tangle-network/agent-eval/analyst';
5
5
  export { Analyst, AnalystContext, AnalystFinding, AnalystRegistry, buildDefaultAnalystRegistry, makeFinding } from '@tangle-network/agent-eval/analyst';
6
6
  import { RedactionRule, RedactionReport } from '@tangle-network/agent-eval/traces';
7
7
  export { DEFAULT_REDACTION_RULES, RedactionReport, RedactionRule, redactString, redactValue } from '@tangle-network/agent-eval/traces';
@@ -14,8 +14,10 @@ export { HostedClient, createHostedClient, hostedClientFromEnv } from '@tangle-n
14
14
  * Every harness adapter projects its native session log onto `OtlpSpan[]`
15
15
  * and we serialize one span per JSONL line. This is the exact wire shape
16
16
  * `@tangle-network/agent-eval`'s `OtlpFileTraceStore` indexes
17
- * (`projectOtlpFlatLine` + `extractOtlpAttributes`) AND the OpenInference
18
- * OTLP form HALO consumes — so one emitted file feeds both engines.
17
+ * (`projectOtlpFlatLine` + `extractOtlpAttributes`). It is a flat subset, NOT
18
+ * canonical OpenInference (kind lives in attributes; no resource/scope; root
19
+ * parent is null) — external OpenInference engines like HALO need the canonical
20
+ * form, which `external.ts` `toCanonicalOpenInference` produces.
19
21
  *
20
22
  * Attribute vocabulary the downstream analysts key off:
21
23
  * - `openinference.span.kind` → span kind (AGENT | LLM | TOOL | CHAIN)
@@ -524,8 +526,10 @@ declare function toRuntimeStore(spans: readonly OtlpSpan[]): Promise<RuntimeTrac
524
526
  * LLM, model-agnostic). Supply `ai` to add the agentic RLM kinds
525
527
  * (failure-mode / knowledge-gap / knowledge-poisoning / improvement).
526
528
  *
527
- * The written OTLP file is the canonical artifact — the same file feeds HALO
528
- * (`halo <file> -p "diagnose"`), so analysis is never locked to one engine.
529
+ * The written OTLP file is the artifact our pipeline reads. External engines can
530
+ * analyze it too: HALO needs canonical OpenInference, so `--analyzer halo`
531
+ * converts it first (see external.ts `toCanonicalOpenInference`) — analysis is
532
+ * never locked to one engine.
529
533
  */
530
534
 
531
535
  interface AnalyzeOptions {
@@ -547,12 +551,109 @@ interface AnalyzeOptions {
547
551
  log?: (msg: string, fields?: Record<string, unknown>) => void;
548
552
  }
549
553
  interface AnalyzeResult {
550
- /** Path to the OTLP-JSONL artifact (feeds HALO too). */
554
+ /** Path to the OTLP-JSONL artifact (convert to canonical for HALO). */
551
555
  otlpPath: string;
552
556
  result: Awaited<ReturnType<ReturnType<typeof buildDefaultAnalystRegistry>['run']>>;
553
557
  }
554
558
  declare function analyzeSpans(spans: readonly OtlpSpan[], opts?: AnalyzeOptions): Promise<AnalyzeResult>;
555
559
 
560
+ /**
561
+ * External-tool adapters: run analysis engines and PII scrubbers that `traces`
562
+ * does NOT bundle. Each is a thin command adapter over a shared subprocess
563
+ * runner — install the tool yourself (HALO, openai/privacy-filter, anything),
564
+ * point an adapter at it, and it composes with the built-in pipeline.
565
+ *
566
+ * - ExternalAnalyzer — run an engine over the emitted OTLP-JSONL artifact and
567
+ * fold its output into the report (HALO and friends).
568
+ * - Redactor — scrub free-form prompt/response text via an external model that
569
+ * catches what the regex pass can't (names, addresses).
570
+ */
571
+
572
+ interface RunResult {
573
+ stdout: string;
574
+ stderr: string;
575
+ code: number | null;
576
+ }
577
+ /** Spawn `command args`, optionally write `input` to stdin, and collect output.
578
+ * Rejects on spawn error, timeout, or output exceeding `maxBuffer`. */
579
+ declare function runCommand(command: string, args?: readonly string[], opts?: {
580
+ input?: string;
581
+ signal?: AbortSignal;
582
+ timeoutMs?: number;
583
+ maxBuffer?: number;
584
+ }): Promise<RunResult>;
585
+ interface ExternalAnalysisResult {
586
+ analyzer: string;
587
+ ok: boolean;
588
+ /** Raw text the engine produced (markdown/JSON/plain — engine-specific). */
589
+ output: string;
590
+ /** Structured findings, when the engine emits parseable JSON. */
591
+ findings?: AnalystFinding[];
592
+ error?: string;
593
+ }
594
+ /** An analysis engine that runs over the emitted OTLP-JSONL artifact — a peer to
595
+ * the built-in analysts, so you get many analyzers beyond our own. */
596
+ interface ExternalAnalyzer {
597
+ name: string;
598
+ analyze(otlpPath: string, opts?: {
599
+ prompt?: string;
600
+ signal?: AbortSignal;
601
+ }): Promise<ExternalAnalysisResult>;
602
+ }
603
+ /** Wrap any CLI that reads the OTLP file and prints analysis to stdout. `args`
604
+ * builds the argv from the artifact path + optional prompt. Non-zero exit →
605
+ * `ok:false` with stderr in `error` (fail-soft: one engine never breaks a run). */
606
+ declare function commandAnalyzer(spec: {
607
+ name: string;
608
+ command: string;
609
+ args: (otlpPath: string, prompt?: string) => string[];
610
+ /** Parse stdout into structured findings; omit to keep raw output only. */
611
+ parse?: (stdout: string) => AnalystFinding[] | undefined;
612
+ timeoutMs?: number;
613
+ }): ExternalAnalyzer;
614
+ /** Convert our OTLP-JSONL (the flat shape our pipeline reads) into the canonical
615
+ * OpenInference span schema HALO's `SpanRecord` requires: top-level `kind`,
616
+ * `resource`, `scope`, and a string (never null) `parent_span_id`. Our own
617
+ * writer keeps the simpler shape for the agent-eval reader; this is the bridge
618
+ * to OpenInference consumers. Input + output are JSONL strings (one span/line). */
619
+ declare function toCanonicalOpenInference(otlpJsonl: string, opts?: {
620
+ service?: string;
621
+ scopeName?: string;
622
+ }): string;
623
+ /** HALO (github.com/context-labs/halo) over the OTLP artifact. HALO requires
624
+ * canonical OpenInference, so this converts our OTLP to a `<path>.halo.jsonl`
625
+ * first, then runs `halo <file> -p <prompt> [-m <model>]`. Install HALO yourself
626
+ * and configure its LLM provider (it uses the OpenAI client — `OPENAI_BASE_URL`
627
+ * / `OPENAI_API_KEY`); this just drives it. */
628
+ declare function haloAnalyzer(opts?: {
629
+ command?: string;
630
+ defaultPrompt?: string;
631
+ model?: string;
632
+ timeoutMs?: number;
633
+ }): ExternalAnalyzer;
634
+ /** Run external analyzers over one OTLP artifact, concurrently. Never throws —
635
+ * a failing engine returns `ok:false`. */
636
+ declare function runExternalAnalyzers(otlpPath: string, analyzers: readonly ExternalAnalyzer[], opts?: {
637
+ prompt?: string;
638
+ signal?: AbortSignal;
639
+ }): Promise<ExternalAnalysisResult[]>;
640
+ /** An external PII/secret scrubber for free-form text — catches what regex
641
+ * can't (names, addresses). Use to harden upload beyond the built-in rules. */
642
+ interface Redactor {
643
+ name: string;
644
+ /** Scrub a batch of texts; returns one scrubbed string per input, in order. */
645
+ redactText(texts: readonly string[]): Promise<string[]>;
646
+ }
647
+ /** Wrap any CLI that speaks the redaction protocol: read a JSON array of strings
648
+ * on stdin, write a same-length JSON array of scrubbed strings on stdout.
649
+ * (A 3-line wrapper adapts tools like `opf`/openai-privacy-filter.) */
650
+ declare function commandRedactor(spec: {
651
+ name?: string;
652
+ command: string;
653
+ args?: string[];
654
+ timeoutMs?: number;
655
+ }): Redactor;
656
+
556
657
  /**
557
658
  * Live trace observer — the reusable, event-driven core behind `traces watch`.
558
659
  *
@@ -642,6 +743,14 @@ interface SpanRedaction {
642
743
  /** Redact every span's attributes + status message. Returns scrubbed spans and
643
744
  * an aggregate report (count + per-rule breakdown) over the whole set. */
644
745
  declare function redactSpans(spans: readonly OtlpSpan[], rules?: RedactionRule[]): SpanRedaction;
746
+ /** Defense-in-depth: run an external {@link Redactor} (e.g. an ML PII model) over
747
+ * every span's captured `content` (prompt/response prose), catching free-form
748
+ * PII the regex pass misses. Compose AFTER `redactSpans`. Returns scrubbed spans
749
+ * and the number of `content` fields the model changed. */
750
+ declare function applyRedactor(spans: readonly OtlpSpan[], redactor: Redactor): Promise<{
751
+ spans: OtlpSpan[];
752
+ changed: number;
753
+ }>;
645
754
 
646
755
  /**
647
756
  * Batch collection seam — `collectSessions` returns redacted spans per session
@@ -750,9 +859,12 @@ interface ExecuteOptions {
750
859
  * span before it leaves the machine (still keeps tool calls, tokens, timing,
751
860
  * loop signal). The strongest privacy posture when prose can't leave. */
752
861
  stripContent?: boolean;
862
+ /** External PII scrubber applied to `content` AFTER the regex pass — catches
863
+ * free-form PII (names, addresses) the rules miss. Ignored if stripContent. */
864
+ redactor?: Redactor;
753
865
  log?: (msg: string) => void;
754
866
  }
755
867
  /** Send the NEW items (or, on dryRun, write the redacted OTLP that would send). */
756
868
  declare function executeUpload(plan: UploadPlan, opts?: ExecuteOptions): Promise<UploadResult>;
757
869
 
758
- export { ATTR, AmpAdapter, type AnalyzeOptions, type AnalyzeResult, CODING_REDACTION_RULES, CONTENT_CAP, ClaudeAdapter, CodexAdapter, type CollectOptions, CopilotAdapter, DEFAULT_HARNESS, type ExecuteOptions, FactoryAdapter, ForgeAdapter, GeminiAdapter, type HarnessTraceAdapter, INGEST_SOURCE_CLI, type LocateOptions, type ObservedLoop, type ObserverOptions, OpencodeAdapter, type OtlpSpan, type OtlpSpanKind, type OtlpStatusCode, PiAdapter, type PipelineOptions, type PipelineReport, type PlanOptions, QwenAdapter, type RuntimeTrace, type ScanOptions, type ScannedSession, type SessionBatch, type SessionRef, type SpanInput, type SpanRedaction, TRACES_REDACTION_RULES, type UploadBackend, type UploadItem, type UploadPlan, type UploadRecord, type UploadResult, type UploadState, alreadyUploaded, analyzeSpans, capText, collectSessions, executeUpload, knownHarnesses, listAdapters, loadState, parseIsoToEpochMs, parseSince, planUpload, redactSpans, resolveAdapter, runPipelines, saveState, scanSessions, selectAdapters, serializeSpans, sessionHash, span, toRuntimeStore, toTraceSpanEvents, uploadKey, userPromptSpan, watchSessions, writeOtlpFile };
870
+ export { ATTR, AmpAdapter, type AnalyzeOptions, type AnalyzeResult, CODING_REDACTION_RULES, CONTENT_CAP, ClaudeAdapter, CodexAdapter, type CollectOptions, CopilotAdapter, DEFAULT_HARNESS, type ExecuteOptions, type ExternalAnalysisResult, type ExternalAnalyzer, FactoryAdapter, ForgeAdapter, GeminiAdapter, type HarnessTraceAdapter, INGEST_SOURCE_CLI, type LocateOptions, type ObservedLoop, type ObserverOptions, OpencodeAdapter, type OtlpSpan, type OtlpSpanKind, type OtlpStatusCode, PiAdapter, type PipelineOptions, type PipelineReport, type PlanOptions, QwenAdapter, type Redactor, type RunResult, type RuntimeTrace, type ScanOptions, type ScannedSession, type SessionBatch, type SessionRef, type SpanInput, type SpanRedaction, TRACES_REDACTION_RULES, type UploadBackend, type UploadItem, type UploadPlan, type UploadRecord, type UploadResult, type UploadState, alreadyUploaded, analyzeSpans, applyRedactor, capText, collectSessions, commandAnalyzer, commandRedactor, executeUpload, haloAnalyzer, knownHarnesses, listAdapters, loadState, parseIsoToEpochMs, parseSince, planUpload, redactSpans, resolveAdapter, runCommand, runExternalAnalyzers, runPipelines, saveState, scanSessions, selectAdapters, serializeSpans, sessionHash, span, toCanonicalOpenInference, toRuntimeStore, toTraceSpanEvents, uploadKey, userPromptSpan, watchSessions, writeOtlpFile };
package/dist/index.js CHANGED
@@ -17,8 +17,12 @@ import {
17
17
  TRACES_REDACTION_RULES,
18
18
  alreadyUploaded,
19
19
  analyzeSpans,
20
+ applyRedactor,
20
21
  capText,
22
+ commandAnalyzer,
23
+ commandRedactor,
21
24
  executeUpload,
25
+ haloAnalyzer,
22
26
  knownHarnesses,
23
27
  listAdapters,
24
28
  loadState,
@@ -27,6 +31,8 @@ import {
27
31
  planUpload,
28
32
  redactSpans,
29
33
  resolveAdapter,
34
+ runCommand,
35
+ runExternalAnalyzers,
30
36
  runPipelines,
31
37
  saveState,
32
38
  scanSessions,
@@ -34,13 +40,14 @@ import {
34
40
  serializeSpans,
35
41
  sessionHash,
36
42
  span,
43
+ toCanonicalOpenInference,
37
44
  toRuntimeStore,
38
45
  toTraceSpanEvents,
39
46
  uploadKey,
40
47
  userPromptSpan,
41
48
  watchSessions,
42
49
  writeOtlpFile
43
- } from "./chunk-HF2KGEBW.js";
50
+ } from "./chunk-Q56AP2EY.js";
44
51
 
45
52
  // src/collect.ts
46
53
  async function collectSessions(opts = {}) {
@@ -85,11 +92,15 @@ export {
85
92
  TRACES_REDACTION_RULES,
86
93
  alreadyUploaded,
87
94
  analyzeSpans,
95
+ applyRedactor,
88
96
  buildDefaultAnalystRegistry,
89
97
  capText,
90
98
  collectSessions,
99
+ commandAnalyzer,
100
+ commandRedactor,
91
101
  createHostedClient,
92
102
  executeUpload,
103
+ haloAnalyzer,
93
104
  hostedClientFromEnv,
94
105
  knownHarnesses,
95
106
  listAdapters,
@@ -102,6 +113,8 @@ export {
102
113
  redactString,
103
114
  redactValue,
104
115
  resolveAdapter,
116
+ runCommand,
117
+ runExternalAnalyzers,
105
118
  runPipelines,
106
119
  saveState,
107
120
  scanSessions,
@@ -109,6 +122,7 @@ export {
109
122
  serializeSpans,
110
123
  sessionHash,
111
124
  span,
125
+ toCanonicalOpenInference,
112
126
  toRuntimeStore,
113
127
  toTraceSpanEvents,
114
128
  uploadKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/traces",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Point it at your coding-agent session traces (Claude Code, Codex, OpenCode, Gemini, Pi, …) and get failure-mode + efficiency findings. CLI + SDK over the @tangle-network/agent-eval analyst suite — observe live sessions, run your own analysts, redact, and upload to the Tangle Intelligence Platform.",
5
5
  "type": "module",
6
6
  "license": "MIT",