@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 +70 -3
- package/dist/cli.js +69 -4
- package/dist/ledger.d.ts +25 -0
- package/dist/ledger.js +52 -5
- package/dist/novelty.d.ts +138 -0
- package/dist/novelty.js +581 -0
- package/dist/report.d.ts +8 -2
- package/dist/report.js +50 -2
- package/dist/wrap.js +115 -1
- package/package.json +1 -1
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
|
|
70
|
-
|
|
71
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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[];
|