@agentx-core/security-sdk 0.1.0 → 0.2.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
@@ -66,9 +66,76 @@ value to that file: the wrapper reduces a call to its shape before anything is w
66
66
  `test/wrap.test.ts` asserts the file holds no value from a call carrying a SQL statement, an
67
67
  email address and a dollar figure. This is exactly what the Python SDK's `agentx audit` keeps.
68
68
 
69
- The file is yours. Add it to `.gitignore`; the wrapper reminds you once when it creates it. It
70
- keeps the last 30 days or 10,000 rows, and when it has trimmed, the report says the counts
71
- describe what was **kept**.
69
+ The files are yours. There are two, side by side: `.agentx-calls.jsonl` is the record, and
70
+ `.agentx-novelty.json` remembers what you had already seen, so the "what changed" block below
71
+ means something. Add both to `.gitignore`; the wrapper names both, once, when it creates the
72
+ record. The record keeps the last 30 days or 10,000 rows, and when it has trimmed, the report
73
+ says the counts describe what was **kept**.
74
+
75
+ ## What changed since you last looked
76
+
77
+ Running `audit` a second time answers a different question from the first. Under the table it
78
+ prints what is new: a tool you have not seen before, a first call to your database or
79
+ filesystem, an argument that got added or dropped, a bigger amount than any before, a busier
80
+ day, a burst inside one minute, a first call at an hour you have not run at, a first weekend
81
+ call, and a return after a quiet stretch of two days or more.
82
+
83
+ It only appears when something actually changed. A quiet run prints nothing at all.
84
+
85
+ ## How many tools you wrapped, against how many ran
86
+
87
+ ```
88
+ 4 calls across 2 tools since 2026-09-08 20:52
89
+ 5 tools wrapped. 2 called. 3 never ran.
90
+ ```
91
+
92
+ The second line is the denominator the call list cannot give you: three tools were handed to
93
+ the model and it never reached for them.
94
+
95
+ ## Reading the record in CI
96
+
97
+ ```bash
98
+ npx @agentx-core/security-sdk@^0.2.0 audit --require-calls
99
+ ```
100
+
101
+ Without that flag, a job where the agent never ran leaves no record, `audit` reads zero calls,
102
+ and the step exits 0. A green tick on a check that measured nothing is worse than no check.
103
+ With it, that case exits 2 and says which of the two reasons it was.
104
+
105
+ | exit | meaning |
106
+ |---|---|
107
+ | 0 | it read the record |
108
+ | 1 | the command line was wrong |
109
+ | 2 | it could not judge anything: the record is unreadable, or `--require-calls` found no calls |
110
+
111
+ `--json` always prints a complete payload, so a pipeline can gate on the exit code and still
112
+ keep the data.
113
+
114
+ A worked GitHub Actions job, inline rather than linked, because the npm package ships `dist`
115
+ only and a link would be a dead end:
116
+
117
+ ```yaml
118
+ - run: npm ci
119
+
120
+ # Your agent, with its tools wrapped. It writes the record into whatever
121
+ # directory it runs in, and audit below reads the one beside it.
122
+ - name: Run the agent
123
+ run: node ./scripts/run-agent.js
124
+
125
+ # Exits 2 if the record holds no calls, so a run where the agent silently
126
+ # did nothing cannot pass as a clean audit.
127
+ - name: What did the agent do
128
+ run: npx @agentx-core/security-sdk@^0.2.0 audit --require-calls
129
+
130
+ - name: Keep the record
131
+ if: always()
132
+ run: npx @agentx-core/security-sdk@^0.2.0 audit --json > agentx-audit.json || true
133
+ ```
134
+
135
+ Two things it deliberately does not do. It does not fail on anything the agent *did*: this
136
+ package has no rules and makes no judgement about a call. And it does not gate on the
137
+ what-changed block, because that works off a stored file and a CI container starts clean, so
138
+ everything would look new on every run.
72
139
 
73
140
  ## Nothing is blocked
74
141
 
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ exports.main = main;
13
13
  const ledger_1 = require("./ledger");
14
14
  const report_1 = require("./report");
15
15
  const pulse_1 = require("./pulse");
16
+ const novelty_1 = require("./novelty");
16
17
  const USAGE = `
17
18
  šŸ”Ž agentx audit — what your agent actually did, from the local record.
18
19
 
@@ -20,6 +21,7 @@ const USAGE = `
20
21
  ${report_1.AUDIT_COMMAND} --calls one line per call, newest first
21
22
  --json machine-readable output (always complete)
22
23
  --limit N | --all how many rows the human screen shows
24
+ --require-calls exit 2 if the ledger holds no calls (for CI)
23
25
  --help this message
24
26
 
25
27
  The record is written by agentxWatch() into .agentx-calls.jsonl in the folder your agent ran
@@ -43,7 +45,9 @@ function main(argv, io = defaultIo) {
43
45
  io.err(USAGE.trim());
44
46
  return 1;
45
47
  }
46
- const opts = { calls: false, json: false };
48
+ const opts = {
49
+ calls: false, json: false, requireCalls: false,
50
+ };
47
51
  const die = (message) => {
48
52
  io.err(`\nāŒ ${message}`);
49
53
  io.err(` Usage: ${report_1.AUDIT_COMMAND} [--calls] [--json] [--limit N | --all]`);
@@ -55,6 +59,8 @@ function main(argv, io = defaultIo) {
55
59
  opts.calls = true;
56
60
  else if (tok === "--json")
57
61
  opts.json = true;
62
+ else if (tok === "--require-calls")
63
+ opts.requireCalls = true;
58
64
  else if (tok === "--all")
59
65
  opts.all = true;
60
66
  else if (tok === "--limit") {
@@ -78,21 +84,80 @@ function main(argv, io = defaultIo) {
78
84
  const file = (0, ledger_1.ledgerPath)(env);
79
85
  (0, ledger_1.trimLedger)(file);
80
86
  const read = (0, ledger_1.readLedger)(file);
87
+ // šŸ”“ "I LOOKED AND IT WAS CLEAN" AND "THERE WAS NOTHING TO LOOK AT" ARE THE SAME EXIT CODE
88
+ // WITHOUT THIS FLAG, and in CI that is the whole problem: a job where the agent never ran
89
+ // leaves no ledger, this command reads zero rows, and a green tick reports a check that
90
+ // observed nothing. Exit 2, the same code an unreadable ledger uses, because both mean the
91
+ // output is not a statement about the agent. Not a new code: 3 is reserved for a gate that
92
+ // looked and FOUND something, which needs rules this package does not have yet.
93
+ //
94
+ // Deliberately BEFORE the `--json` branch's return but AFTER the payload is printed, so the
95
+ // machine path still emits complete JSON and only the exit code carries the failure.
96
+ const noCallsToJudge = opts.requireCalls && read.readable && read.rows.length === 0;
81
97
  if (opts.json) {
82
98
  io.out(JSON.stringify((0, report_1.jsonPayload)(read, opts.calls ? "calls" : "tools", opts), null, 2));
83
- return read.readable ? 0 : 2;
99
+ if (!read.readable)
100
+ return 2;
101
+ if (noCallsToJudge) {
102
+ io.err("\nāŒ --require-calls: this ledger holds no calls, so there is nothing to judge.");
103
+ return 2;
104
+ }
105
+ return 0;
84
106
  }
85
107
  // A human looked. The rung this package exists to make visible, and the one thing `--json`
86
108
  // must never climb: a cron job polling it is not a person.
87
- (0, pulse_1.markAuditReportRun)(undefined, env);
109
+ //
110
+ // šŸ”“ `--require-calls` DOES NOT CLIMB IT EITHER, for the same reason. Its own usage line says
111
+ // "for CI", so it is a reliable statement that nobody is reading. `isAutomationContext` catches
112
+ // the usual thirteen CI variables, but a self-hosted runner, a cron job or a bare container may
113
+ // set none of them, and this mark is STICKY and cannot be un-climbed: one machine invocation
114
+ // would record the install converted forever.
115
+ if (!opts.requireCalls)
116
+ (0, pulse_1.markAuditReportRun)(undefined, env);
88
117
  if (!read.readable) {
89
118
  io.err("\nāŒ The ledger is on disk but could not be read, so this is not a statement that");
90
119
  io.err(` your agent did nothing. ${file}`);
91
120
  return 2;
92
121
  }
93
- io.out(opts.calls ? (0, report_1.renderCalls)(read, opts) : (0, report_1.renderTools)(read, opts));
122
+ // šŸ”“ THE FAILURE IS THE WHOLE OUTPUT, and the empty screen is skipped rather than printed
123
+ // above it. `--require-calls` is a CI flag; on a job with no ledger the human screen put twelve
124
+ // lines of onboarding, including a "here is how to wrap a tool" snippet, in front of the three
125
+ // lines a build log actually needs. Found in a founder walk. Nothing is lost: the error names
126
+ // the ledger path itself, and names both reasons including the unwrapped one.
127
+ if (noCallsToJudge)
128
+ return requireCallsFailed(io, file);
129
+ if (opts.calls) {
130
+ io.out((0, report_1.renderCalls)(read, opts));
131
+ return 0;
132
+ }
133
+ // NOVELTY, on the grouped screen only, matching the Python audit. Read first, print, and move
134
+ // the mark only afterwards: a reader whose terminal dies mid-screen would otherwise lose that
135
+ // novelty permanently. `--json` gets none of this on purpose -- "since you last looked" needs
136
+ // a reader who looks, and a job polling a JSON endpoint has no last look to be since.
137
+ //
138
+ // šŸ”“ AND `--require-calls` GETS NONE OF IT EITHER, WHICH IS THE SAME RULE AS THE PULSE MARK
139
+ // ABOVE AND WAS MISSED THERE FIRST. There are TWO sticky things a run can consume: the pulse's
140
+ // "a human read the report", and this mark. The first version of the CI carve-out skipped only
141
+ // the pulse, four lines up, and left this one consuming. On a build machine that keeps its
142
+ // workspace between runs -- a self-hosted runner, a Makefile target, a pre-commit hook -- the
143
+ // CI invocation ate "NEW SINCE YOU LAST LOOKED" and the developer's next `audit` printed
144
+ // nothing. A flag that says "for CI" in its own usage line must not climb OR consume anything.
145
+ if (opts.requireCalls) {
146
+ io.out((0, report_1.renderTools)(read, opts));
147
+ return 0;
148
+ }
149
+ const marks = (0, novelty_1.noveltyPath)(env);
150
+ const novelty = (0, novelty_1.readNovelty)("report", read.rows, marks);
151
+ io.out((0, report_1.renderTools)(read, opts, (0, novelty_1.renderNovelty)(novelty)));
152
+ (0, novelty_1.advanceMark)("report", novelty.current, marks);
94
153
  return 0;
95
154
  }
155
+ function requireCallsFailed(io, file) {
156
+ io.err("\nāŒ --require-calls: this ledger holds no calls, so there is nothing to judge.");
157
+ io.err(` Nothing has been recorded in ${file}`);
158
+ io.err(" Either the agent did not run, or its tools are not wrapped with agentxWatch().");
159
+ return 2;
160
+ }
96
161
  // Only auto-run as the real entry point (the published `bin`), never when a test imports `main`.
97
162
  if (require.main === module && !process.env.VITEST) {
98
163
  process.exitCode = main(process.argv.slice(2));
package/dist/ledger.d.ts CHANGED
@@ -13,12 +13,29 @@ export interface CallRow {
13
13
  quantity: number;
14
14
  agent: string;
15
15
  }
16
+ /**
17
+ * The tools that were WRAPPED, whether or not any of them ran. The denominator the call rows
18
+ * cannot supply: a wrapped tool that never executed appears in no call row, so without this the
19
+ * screen can say what the agent did and not what it could have done.
20
+ *
21
+ * Carries a per-process `session` because a program may wrap several tool maps, and the answer
22
+ * wanted is "what could this run have called", not "every tool this folder has ever held". The
23
+ * reader takes the newest roster row's session and unions every row sharing it.
24
+ */
25
+ export interface RosterRow {
26
+ k: "roster";
27
+ ts: number;
28
+ session: string;
29
+ tools: string[];
30
+ }
16
31
  /** Where the ledger lives. `AGENTX_TS_LEDGER_PATH` moves it (tests, and a shared folder). */
17
32
  export declare function ledgerPath(env?: NodeJS.ProcessEnv): string;
18
33
  /** Append one call. Returns whether this write CREATED the file (the first-run notice keys on it). */
19
34
  export declare function appendCall(row: CallRow, file?: string): {
20
35
  created: boolean;
21
36
  };
37
+ /** Write this run's roster. Never creates the file: a roster with no calls is not a record of a run. */
38
+ export declare function appendRoster(row: RosterRow, file?: string): void;
22
39
  export interface LedgerRead {
23
40
  path: string;
24
41
  exists: boolean;
@@ -28,6 +45,8 @@ export interface LedgerRead {
28
45
  rows: CallRow[];
29
46
  /** Cumulative rows dropped by retention. 0 means the rows are everything ever recorded. */
30
47
  dropped: number;
48
+ /** Every tool the most recent run WRAPPED, called or not. Empty on a ledger with no roster row. */
49
+ roster: string[];
31
50
  coversAll: boolean;
32
51
  /** Epoch ms of the oldest kept row, or null. */
33
52
  windowStart: number | null;
@@ -35,6 +54,12 @@ export interface LedgerRead {
35
54
  bytes: number;
36
55
  cleanEnd: boolean;
37
56
  }
57
+ /**
58
+ * The newest run's roster: the session id on the latest roster row, then every tool named by any
59
+ * row sharing it. Sorted, deduplicated, and empty when the ledger holds no roster row at all,
60
+ * which is every ledger written before this shipped.
61
+ */
62
+ export declare function latestRoster(rosters: readonly RosterRow[]): string[];
38
63
  export declare function readLedger(file?: string): LedgerRead;
39
64
  /**
40
65
  * Apply retention: drop rows older than RETENTION_DAYS, then the oldest past RETENTION_MAX_ROWS.
package/dist/ledger.js CHANGED
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RETENTION_MAX_ROWS = exports.RETENTION_DAYS = exports.LEDGER_FILE = void 0;
37
37
  exports.ledgerPath = ledgerPath;
38
38
  exports.appendCall = appendCall;
39
+ exports.appendRoster = appendRoster;
40
+ exports.latestRoster = latestRoster;
39
41
  exports.readLedger = readLedger;
40
42
  exports.trimLedger = trimLedger;
41
43
  /**
@@ -65,8 +67,20 @@ function appendCall(row, file = ledgerPath()) {
65
67
  fs.appendFileSync(file, JSON.stringify(row) + "\n", "utf8");
66
68
  return { created };
67
69
  }
70
+ /** Write this run's roster. Never creates the file: a roster with no calls is not a record of a run. */
71
+ function appendRoster(row, file = ledgerPath()) {
72
+ try {
73
+ if (!fs.existsSync(file))
74
+ return;
75
+ fs.appendFileSync(file, JSON.stringify(row) + "\n", "utf8");
76
+ }
77
+ catch {
78
+ /* the denominator is a nicety; losing it is never worth a broken exit */
79
+ }
80
+ }
68
81
  function parseLines(text) {
69
82
  const rows = [];
83
+ const rosters = [];
70
84
  let dropped = 0;
71
85
  for (const line of text.split("\n")) {
72
86
  if (!line.trim())
@@ -82,6 +96,9 @@ function parseLines(text) {
82
96
  if (r && r.k === "trim" && typeof r.dropped === "number") {
83
97
  dropped = Math.max(dropped, r.dropped);
84
98
  }
99
+ else if (r && r.k === "roster" && Array.isArray(r.tools) && typeof r.ts === "number") {
100
+ rosters.push({ k: "roster", ts: r.ts, session: String(r.session ?? ""), tools: r.tools.map(String) });
101
+ }
85
102
  else if (r && r.k === "call" && typeof r.tool === "string" && typeof r.ts === "number") {
86
103
  rows.push({
87
104
  k: "call",
@@ -95,12 +112,29 @@ function parseLines(text) {
95
112
  });
96
113
  }
97
114
  }
98
- return { rows, dropped };
115
+ return { rows, dropped, rosters };
116
+ }
117
+ /**
118
+ * The newest run's roster: the session id on the latest roster row, then every tool named by any
119
+ * row sharing it. Sorted, deduplicated, and empty when the ledger holds no roster row at all,
120
+ * which is every ledger written before this shipped.
121
+ */
122
+ function latestRoster(rosters) {
123
+ if (!rosters.length)
124
+ return [];
125
+ const newest = rosters.reduce((a, b) => (b.ts >= a.ts ? b : a));
126
+ const names = new Set();
127
+ for (const r of rosters) {
128
+ if (r.session === newest.session)
129
+ for (const t of r.tools)
130
+ names.add(t);
131
+ }
132
+ return [...names].sort();
99
133
  }
100
134
  function readLedger(file = ledgerPath()) {
101
135
  const base = {
102
- path: file, exists: false, readable: true, rows: [], dropped: 0, coversAll: true, windowStart: null,
103
- bytes: 0, cleanEnd: true,
136
+ path: file, exists: false, readable: true, rows: [], dropped: 0, roster: [], coversAll: true,
137
+ windowStart: null, bytes: 0, cleanEnd: true,
104
138
  };
105
139
  if (!fs.existsSync(file))
106
140
  return base;
@@ -114,12 +148,13 @@ function readLedger(file = ledgerPath()) {
114
148
  // statement about the developer's own data.
115
149
  return { ...base, readable: false };
116
150
  }
117
- const { rows, dropped } = parseLines(buf.toString("utf8"));
151
+ const { rows, dropped, rosters } = parseLines(buf.toString("utf8"));
118
152
  rows.sort((a, b) => a.ts - b.ts);
119
153
  return {
120
154
  ...base,
121
155
  rows,
122
156
  dropped,
157
+ roster: latestRoster(rosters),
123
158
  coversAll: dropped === 0,
124
159
  windowStart: rows.length ? rows[0].ts : null,
125
160
  bytes: buf.length,
@@ -145,7 +180,19 @@ function trimLedger(file = ledgerPath(), now = Date.now(), maxAgeDays = exports.
145
180
  if (droppedNow === 0)
146
181
  return { dropped: 0 };
147
182
  const trim = { k: "trim", ts: now, dropped: read.dropped + droppedNow };
148
- const lines = [JSON.stringify(trim), ...kept.map((r) => JSON.stringify(r))].join("\n") + "\n";
183
+ // šŸ”“ THE ROSTER IS CARRIED THROUGH THE REWRITE, and leaving it out was the first version of
184
+ // this. `read.rows` holds CALL rows only, so a rewrite built from them alone silently drops
185
+ // the roster and the screen loses its denominator the first time retention fires. The
186
+ // carried row is the newest run's, rebuilt from `read.roster`, so the rewrite is also where
187
+ // older runs' rosters are collapsed away rather than accumulating forever.
188
+ const carried = read.roster.length
189
+ ? [{ k: "roster", ts: now, session: "carried", tools: read.roster }]
190
+ : [];
191
+ const lines = [
192
+ JSON.stringify(trim),
193
+ ...carried.map((r) => JSON.stringify(r)),
194
+ ...kept.map((r) => JSON.stringify(r)),
195
+ ].join("\n") + "\n";
149
196
  fs.writeFileSync(tmp, lines, "utf8");
150
197
  // Other processes write this file too: an agent appending in another terminal, or a second
151
198
  // exiting agent running its own trim. Rows appended after the read (the file grew, and what
@@ -0,0 +1,138 @@
1
+ import { type CallRow } from "./ledger";
2
+ import { type TargetClass } from "./shape";
3
+ /** The two screens that read novelty, each with its own mark. See decision 2 above. */
4
+ export type Mark = "report" | "session";
5
+ /** How many lines one screen prints before it counts the rest. */
6
+ export declare const MAX_NOVELTY_SHOWN = 6;
7
+ /** Per-tool totals. Every field only ever moves up, which is what makes decision 1 work. */
8
+ export interface ToolTotals {
9
+ calls: number;
10
+ /** Every argument name ever seen on this tool. */
11
+ args: string[];
12
+ /** Every distinct argument-name COMBINATION, as sorted joined keys. "" is a real member (a call with no arguments). */
13
+ combos: string[];
14
+ /** Every surface class ever seen. */
15
+ classes: TargetClass[];
16
+ maxAmount: number;
17
+ /** Most calls on any one local calendar day, and which day. */
18
+ maxDayCalls: number;
19
+ maxDay: string;
20
+ /** Most calls inside any one local minute, and WHICH minute. See `recordChanged`. */
21
+ maxBurst: number;
22
+ maxBurstAt: string;
23
+ /** Every local hour-of-day this tool has run in. */
24
+ hours: number[];
25
+ weekend: boolean;
26
+ /** Epoch ms of the most recent call. The one field that is a time, and it is not what novelty is diffed on. */
27
+ lastTs: number;
28
+ }
29
+ export interface Totals {
30
+ tools: Record<string, ToolTotals>;
31
+ busiest: string;
32
+ busiestCalls: number;
33
+ totalCalls: number;
34
+ distinctTools: number;
35
+ }
36
+ export type NoveltyKind = "tool" | "surface" | "argument" | "dropped" | "amount" | "busiest" | "busiest_day" | "burst" | "hour" | "weekend" | "gap";
37
+ export interface NoveltyItem {
38
+ kind: NoveltyKind;
39
+ /** Empty for a fact about the session rather than about one tool. See `renderNovelty`. */
40
+ tool: string;
41
+ detail?: unknown;
42
+ }
43
+ /**
44
+ * Roll the kept rows up into per-tool totals. Pure; takes rows and returns a value.
45
+ *
46
+ * āš ļø THIS IS COMPUTED FROM WHAT THE LEDGER STILL HOLDS, so it can be LOWER than a stored mark
47
+ * after a trim. That is exactly the case decision 1 exists for, and `diffNovelty` only ever
48
+ * reports a rise, never a fall.
49
+ */
50
+ export declare function computeTotals(rows: readonly CallRow[]): Totals;
51
+ /**
52
+ * What is new in `current` that was not in `previous`. Pure.
53
+ *
54
+ * šŸ”“ EVERY COMPARISON IS "HAS THIS RISEN", NEVER "HAS THIS CHANGED". A trim can only make the
55
+ * computed totals smaller, and a smaller number is not news.
56
+ */
57
+ export declare function diffNovelty(previous: Totals | null, current: Totals, rows?: readonly CallRow[]): NoveltyItem[];
58
+ /**
59
+ * The stored mark, RATCHETED: every maximum and every set only ever grows.
60
+ *
61
+ * šŸ”“ WITHOUT THIS, DECISION 1 AT THE TOP OF THIS FILE IS A COMMENT AND NOT A BEHAVIOUR.
62
+ * `diffNovelty` only ever reports a rise, but `advanceMark` used to store the freshly computed
63
+ * totals wholesale, and those are computed over the KEPT rows. So a trim moved the mark DOWN.
64
+ * Concretely: `chargeCard` runs at 1,000,000 and the mark records it; thirty-one days later that
65
+ * row is trimmed and the next read stores `maxAmount: 500`; a later call at 900 is then
66
+ * announced as "largest amount yet: ≄900". The same held for the surface set (a repeat "first
67
+ * call to your database"), the argument names, the busiest day and the burst.
68
+ *
69
+ * `busiest` is deliberately NOT ratcheted: "now your busiest tool" is a claim about the present,
70
+ * and a genuine change of leader is news each time it happens.
71
+ *
72
+ * āš ļø AND IT IS UNBOUNDED, WHICH IS A KNOWN, DELIBERATE, WRITTEN-DOWN GAP. Before the ratchet the
73
+ * mark was recomputed from the kept rows, so the ledger's retention capped it as a side effect
74
+ * nobody had decided; merging removed that cap. A program naming tools dynamically, one per
75
+ * tenant or per resource, accumulates every name it has ever used.
76
+ *
77
+ * šŸ”“ AN EVICTION RULE WAS WRITTEN AND THEN REVERTED, AND THE REASON IS THE POINT. Dropping tools
78
+ * whose last call had aged out of the ledger reintroduced this function's own worked example: a
79
+ * `chargeCard` that runs monthly at 1,000,000 had its entry evicted on day 31 and, on day 32,
80
+ * was announced as "first time we have seen this tool" with "largest amount yet: ≄900". The
81
+ * justification, that a tool aged out of the ledger cannot be diffed anyway, was false: the
82
+ * STORED MARK is the other side of the diff, which is the entire reason totals are stored rather
83
+ * than read back off the rows. Any eviction keyed on time has this defect for any tool that runs
84
+ * less often than the window. A cap on the NUMBER of entries would not, and that is what the
85
+ * backlog row asks for. Growth is slow and hypothetical; the bug the eviction caused was
86
+ * immediate, so the gap is carried openly instead.
87
+ */
88
+ export declare function mergeMark(previous: Totals | null, current: Totals): Totals;
89
+ /** The words for one item, in ONE place because two screens print them. "" for anything unrecognised. */
90
+ export declare function formatNoveltyItem(item: NoveltyItem): string;
91
+ /**
92
+ * The items that actually produce a line. `formatNoveltyItem` returns "" for a kind it cannot
93
+ * word (a surface with no plain noun, an empty argument list), so a raw `items.length` counts
94
+ * things no reader will ever see. Both screens count off THIS, so the session line's number and
95
+ * the report's "(N more not listed)" cannot disagree.
96
+ */
97
+ export declare function formattableItems(items: readonly NoveltyItem[]): NoveltyItem[];
98
+ export declare function topNovelty(items: readonly NoveltyItem[], limit: number): NoveltyItem[];
99
+ export declare const NOVELTY_FILE = ".agentx-novelty.json";
100
+ /** Beside the ledger, so moving the ledger moves the mark with it. */
101
+ export declare function noveltyPath(env?: NodeJS.ProcessEnv): string;
102
+ export interface NoveltyRead {
103
+ items: NoveltyItem[];
104
+ /** True when this screen has no stored mark yet. The header changes; see `renderNovelty`. */
105
+ firstLook: boolean;
106
+ /** The totals a later `advanceMark` should store. */
107
+ current: Totals;
108
+ }
109
+ /**
110
+ * PURE READ. Works out what is new for one screen and stores nothing. `advanceMark` is the
111
+ * separate, deliberate second step. See decision 3.
112
+ */
113
+ export declare function readNovelty(mark: Mark, rows: readonly CallRow[], file?: string): NoveltyRead;
114
+ /**
115
+ * Store the totals for one screen. Called only after the screen has actually been written.
116
+ *
117
+ * šŸ”“ MERGED INTO THE STORED MARK, NEVER WRITTEN OVER IT. `current` is computed from the rows the
118
+ * ledger still holds, so writing it wholesale moves the mark DOWN after a trim and re-announces
119
+ * an ordinary call as a record. See `mergeMark`.
120
+ */
121
+ export declare function advanceMark(mark: Mark, current: Totals, file?: string): void;
122
+ /**
123
+ * The ONE line the session-end screen prints: how much is new, and where to read it. "" when
124
+ * there is nothing to say, so the caller cannot print a bare pointer to an empty report.
125
+ *
126
+ * A pure function on purpose. It runs from a process exit hook, where a test cannot see it, and
127
+ * a sentence is exactly the thing no test reddens unless it is reachable. This one is.
128
+ */
129
+ export declare function sessionNoveltyLine(read: NoveltyRead): string;
130
+ /**
131
+ * The block both screens print, as lines. Empty array when there is nothing to say, so a caller
132
+ * cannot accidentally print a bare header over nothing.
133
+ *
134
+ * TWO HEADERS, because they are two different claims. On a ledger we have never marked, every
135
+ * tool in it is new to US, not new to the agent, and "first call to your database" under a
136
+ * "since you last looked" header would date a year of history to this afternoon.
137
+ */
138
+ export declare function renderNovelty(read: NoveltyRead, indent?: string): string[];