@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/dist/novelty.js
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.NOVELTY_FILE = exports.MAX_NOVELTY_SHOWN = void 0;
|
|
37
|
+
exports.computeTotals = computeTotals;
|
|
38
|
+
exports.diffNovelty = diffNovelty;
|
|
39
|
+
exports.mergeMark = mergeMark;
|
|
40
|
+
exports.formatNoveltyItem = formatNoveltyItem;
|
|
41
|
+
exports.formattableItems = formattableItems;
|
|
42
|
+
exports.topNovelty = topNovelty;
|
|
43
|
+
exports.noveltyPath = noveltyPath;
|
|
44
|
+
exports.readNovelty = readNovelty;
|
|
45
|
+
exports.advanceMark = advanceMark;
|
|
46
|
+
exports.sessionNoveltyLine = sessionNoveltyLine;
|
|
47
|
+
exports.renderNovelty = renderNovelty;
|
|
48
|
+
/**
|
|
49
|
+
* NOVELTY: what changed about this ledger since the reader last looked.
|
|
50
|
+
*
|
|
51
|
+
* A port of the novelty reader in `agentx_sdk/db.py` and `agentx_sdk/cli.py`. The record
|
|
52
|
+
* already answers "what did my agent do". This answers "what is different", which is the
|
|
53
|
+
* question worth coming back for: we only ever flag a small subset of calls, so a line about a
|
|
54
|
+
* call we would have stopped cannot be the reason anyone runs `audit` twice.
|
|
55
|
+
*
|
|
56
|
+
* It blocks nothing and judges nothing. Every line here is arithmetic over rows the ledger
|
|
57
|
+
* already holds, so this file adds no new field, no new write on the hot path, and nothing new
|
|
58
|
+
* that leaves the machine.
|
|
59
|
+
*
|
|
60
|
+
* 🔴 THREE DECISIONS THAT LOOK LIKE DETAILS AND ARE NOT. Each is copied from the Python side
|
|
61
|
+
* with its reason, because each was paid for there:
|
|
62
|
+
*
|
|
63
|
+
* 1. THE MARK IS A STORED COPY OF THE TOTALS, NEVER A TIMESTAMP AND NEVER A ROW OFFSET
|
|
64
|
+
* (`db.py:2533`). The ledger trims at 30 days or 10,000 rows. A "largest amount yet" read
|
|
65
|
+
* back off the ROWS therefore goes DOWN when the big row is trimmed, and the next ordinary
|
|
66
|
+
* call gets announced as a new record. A stored mark only ever moves up.
|
|
67
|
+
*
|
|
68
|
+
* 2. TWO MARKS, NOT ONE (`cli.py:4355`). The session-end line and `audit` are two screens. With
|
|
69
|
+
* one shared mark, whichever printed first ATE the news and the other showed nothing.
|
|
70
|
+
*
|
|
71
|
+
* 3. READING NEVER CONSUMES (`cli.py:409`, `cli.py:3077`). `readNovelty` is pure; the mark moves
|
|
72
|
+
* only when `advanceMark` is called, after the screen has actually been written. A reader
|
|
73
|
+
* whose terminal dies mid-screen otherwise loses that novelty permanently.
|
|
74
|
+
*/
|
|
75
|
+
const fs = __importStar(require("fs"));
|
|
76
|
+
const path = __importStar(require("path"));
|
|
77
|
+
const ledger_1 = require("./ledger");
|
|
78
|
+
const shape_1 = require("./shape");
|
|
79
|
+
const report_1 = require("./report");
|
|
80
|
+
/** How many lines one screen prints before it counts the rest. */
|
|
81
|
+
exports.MAX_NOVELTY_SHOWN = 6;
|
|
82
|
+
/**
|
|
83
|
+
* Plain words for "first call to your ___". Deliberately NOT `SURFACE_LABELS`, which is the
|
|
84
|
+
* short column spelling (DB, FS): "first call to your DB" reads like a column header, not a
|
|
85
|
+
* sentence. A class with no word here is DROPPED rather than rendered as a raw enum, which is
|
|
86
|
+
* also why `other` is absent: "first call to your other" says nothing.
|
|
87
|
+
*/
|
|
88
|
+
const SURFACE_WORDS = {
|
|
89
|
+
db: "database",
|
|
90
|
+
filesystem: "filesystem",
|
|
91
|
+
http: "network",
|
|
92
|
+
shell: "shell",
|
|
93
|
+
cloud: "cloud account",
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The separator joining an argument-name COMBINATION into one storable string.
|
|
97
|
+
*
|
|
98
|
+
* 🔴 WRITTEN AS AN ESCAPE, NEVER AS THE RAW BYTE. A literal NUL in the source makes git classify
|
|
99
|
+
* this whole file as BINARY: the branch that added it showed `novelty.ts | Bin 0 -> 18719 bytes`,
|
|
100
|
+
* so the largest new file in the change was invisible to every diff-based review, to git blame,
|
|
101
|
+
* and to textual merge. Caught in review, after the file had already been through one.
|
|
102
|
+
*
|
|
103
|
+
* NUL and not a space, because it cannot occur inside an argument name, so a combination can
|
|
104
|
+
* never be split back wrongly.
|
|
105
|
+
*/
|
|
106
|
+
const COMBO_SEP = "\u0000";
|
|
107
|
+
/** How many argument names one line will list before it counts the rest. */
|
|
108
|
+
const MAX_ARGS_SHOWN = 3;
|
|
109
|
+
/**
|
|
110
|
+
* How long a silence has to be before "first call in N days" is worth printing. TWO days, not
|
|
111
|
+
* one, and the extra day is the whole point: an agent on a daily schedule leaves a 24-hour gap
|
|
112
|
+
* every single time, so a one-day threshold prints "first call in 1 day" on every run forever.
|
|
113
|
+
* At two days the sentence says something a reader did not already know.
|
|
114
|
+
*/
|
|
115
|
+
const MIN_GAP_MS = 2 * 86400000;
|
|
116
|
+
/** Local calendar day, as YYYY-MM-DD. Local, not UTC: "busiest day" means the developer's day. */
|
|
117
|
+
function localDay(ts) {
|
|
118
|
+
const d = new Date(ts);
|
|
119
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
120
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 🔴 THE ONE RULE EVERY RECURRING SIGNAL GOES THROUGH: a record is news when its HOLDER changes,
|
|
124
|
+
* never when its VALUE moves. Ported from `agentx_sdk/db.py::_record_changed`, which is where it
|
|
125
|
+
* was decided; this is the TypeScript entry point so a fourth record cannot get it wrong.
|
|
126
|
+
*
|
|
127
|
+
* Three signals here are records rather than firsts: busiest tool, busiest day, biggest burst.
|
|
128
|
+
* Comparing the VALUE re-announces a record every time it grows, and a still-open day grows all
|
|
129
|
+
* day. A developer running an agent three times on one Tuesday was told "busiest day yet" three
|
|
130
|
+
* times with a bigger number each time, and the third of those sessions made two calls. Comparing
|
|
131
|
+
* the IDENTITY -- which day, which minute, which tool -- says it once, when it is actually news.
|
|
132
|
+
*
|
|
133
|
+
* Each signal therefore stores the identity of its record beside the value: `maxDay`,
|
|
134
|
+
* `maxBurstAt`, and the busiest tool's own name.
|
|
135
|
+
*/
|
|
136
|
+
function recordChanged(nowId, wasId) {
|
|
137
|
+
return Boolean(nowId) && nowId !== wasId;
|
|
138
|
+
}
|
|
139
|
+
function emptyToolTotals() {
|
|
140
|
+
return {
|
|
141
|
+
calls: 0, args: [], combos: [], classes: [], maxAmount: 0,
|
|
142
|
+
maxDayCalls: 0, maxDay: "", maxBurst: 0, maxBurstAt: "", hours: [], weekend: false, lastTs: 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Roll the kept rows up into per-tool totals. Pure; takes rows and returns a value.
|
|
147
|
+
*
|
|
148
|
+
* ⚠️ THIS IS COMPUTED FROM WHAT THE LEDGER STILL HOLDS, so it can be LOWER than a stored mark
|
|
149
|
+
* after a trim. That is exactly the case decision 1 exists for, and `diffNovelty` only ever
|
|
150
|
+
* reports a rise, never a fall.
|
|
151
|
+
*/
|
|
152
|
+
function computeTotals(rows) {
|
|
153
|
+
const tools = {};
|
|
154
|
+
const perDay = {};
|
|
155
|
+
const perMinute = {};
|
|
156
|
+
for (const r of rows) {
|
|
157
|
+
const name = r.tool || "(unnamed)";
|
|
158
|
+
const t = (tools[name] ??= emptyToolTotals());
|
|
159
|
+
t.calls += 1;
|
|
160
|
+
const names = [...r.args].sort();
|
|
161
|
+
for (const a of names)
|
|
162
|
+
if (!t.args.includes(a))
|
|
163
|
+
t.args.push(a);
|
|
164
|
+
// The combination is joined into ONE string so the empty set survives as a real member.
|
|
165
|
+
// `_dumps_set`'s note on the Python side is about the same hazard from the other direction:
|
|
166
|
+
// "the call took no arguments" is a fact worth announcing, and a separator-join of an empty
|
|
167
|
+
// list is indistinguishable from a missing value unless it is stored deliberately.
|
|
168
|
+
const combo = names.join(COMBO_SEP);
|
|
169
|
+
if (!t.combos.includes(combo))
|
|
170
|
+
t.combos.push(combo);
|
|
171
|
+
if (r.surface && r.surface !== shape_1.CLASS_OTHER && !t.classes.includes(r.surface))
|
|
172
|
+
t.classes.push(r.surface);
|
|
173
|
+
if (typeof r.amount === "number" && r.amount > t.maxAmount)
|
|
174
|
+
t.maxAmount = r.amount;
|
|
175
|
+
const when = new Date(r.ts);
|
|
176
|
+
const hour = when.getHours();
|
|
177
|
+
if (!t.hours.includes(hour))
|
|
178
|
+
t.hours.push(hour);
|
|
179
|
+
const dow = when.getDay();
|
|
180
|
+
if (dow === 0 || dow === 6)
|
|
181
|
+
t.weekend = true;
|
|
182
|
+
if (r.ts > t.lastTs)
|
|
183
|
+
t.lastTs = r.ts;
|
|
184
|
+
const day = localDay(r.ts);
|
|
185
|
+
const days = (perDay[name] ??= {});
|
|
186
|
+
days[day] = (days[day] ?? 0) + 1;
|
|
187
|
+
const minute = Math.floor(r.ts / 60000);
|
|
188
|
+
const minutes = (perMinute[name] ??= {});
|
|
189
|
+
minutes[minute] = (minutes[minute] ?? 0) + 1;
|
|
190
|
+
}
|
|
191
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
192
|
+
for (const [day, n] of Object.entries(perDay[name] ?? {})) {
|
|
193
|
+
if (n > t.maxDayCalls) {
|
|
194
|
+
t.maxDayCalls = n;
|
|
195
|
+
t.maxDay = day;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const [minute, n] of Object.entries(perMinute[name] ?? {})) {
|
|
199
|
+
if (n > t.maxBurst) {
|
|
200
|
+
t.maxBurst = n;
|
|
201
|
+
t.maxBurstAt = minute;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
t.args.sort();
|
|
205
|
+
t.combos.sort();
|
|
206
|
+
t.classes.sort();
|
|
207
|
+
t.hours.sort((a, b) => a - b);
|
|
208
|
+
}
|
|
209
|
+
let busiest = "", busiestCalls = 0;
|
|
210
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
211
|
+
// Ties keep the name already held, so a stable screen does not flicker between two tools
|
|
212
|
+
// sitting on the same count.
|
|
213
|
+
if (t.calls > busiestCalls) {
|
|
214
|
+
busiest = name;
|
|
215
|
+
busiestCalls = t.calls;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
tools, busiest, busiestCalls,
|
|
220
|
+
totalCalls: rows.length,
|
|
221
|
+
distinctTools: Object.keys(tools).length,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* What is new in `current` that was not in `previous`. Pure.
|
|
226
|
+
*
|
|
227
|
+
* 🔴 EVERY COMPARISON IS "HAS THIS RISEN", NEVER "HAS THIS CHANGED". A trim can only make the
|
|
228
|
+
* computed totals smaller, and a smaller number is not news.
|
|
229
|
+
*/
|
|
230
|
+
function diffNovelty(previous, current, rows = []) {
|
|
231
|
+
const items = [];
|
|
232
|
+
const prevTools = previous?.tools ?? {};
|
|
233
|
+
for (const [name, cur] of Object.entries(current.tools)) {
|
|
234
|
+
const prev = prevTools[name];
|
|
235
|
+
if (!prev) {
|
|
236
|
+
items.push({ kind: "tool", tool: name });
|
|
237
|
+
// A brand new tool's surface, arguments and hours are not eleven separate pieces of news;
|
|
238
|
+
// they are what "first time we have seen this tool" already means. Only its surface is
|
|
239
|
+
// worth naming beside it, because that is the fact the developer cannot infer from a name.
|
|
240
|
+
for (const c of cur.classes) {
|
|
241
|
+
if (SURFACE_WORDS[c])
|
|
242
|
+
items.push({ kind: "surface", tool: name, detail: c });
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
for (const c of cur.classes) {
|
|
247
|
+
if (!prev.classes.includes(c) && SURFACE_WORDS[c]) {
|
|
248
|
+
items.push({ kind: "surface", tool: name, detail: c });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const newArgs = cur.args.filter((a) => !prev.args.includes(a));
|
|
252
|
+
if (newArgs.length)
|
|
253
|
+
items.push({ kind: "argument", tool: name, detail: newArgs });
|
|
254
|
+
// A call that DROPPED an argument it had always carried: `deleteRows` called without
|
|
255
|
+
// `where` for the first time is the shape worth noticing, and it is not visible from the
|
|
256
|
+
// argument list growing.
|
|
257
|
+
for (const combo of cur.combos) {
|
|
258
|
+
if (prev.combos.includes(combo))
|
|
259
|
+
continue;
|
|
260
|
+
const present = new Set(combo ? combo.split(COMBO_SEP) : []);
|
|
261
|
+
const missing = prev.args.filter((a) => !present.has(a));
|
|
262
|
+
// Only when the tool had a settled argument list to depart from: on a tool seen once,
|
|
263
|
+
// every new combination "drops" something and the line is noise.
|
|
264
|
+
if (missing.length && prev.combos.length === 1) {
|
|
265
|
+
items.push({ kind: "dropped", tool: name, detail: missing });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (cur.maxAmount > prev.maxAmount)
|
|
269
|
+
items.push({ kind: "amount", tool: name, detail: cur.maxAmount });
|
|
270
|
+
// Both of these are records, so both go through `recordChanged` rather than comparing the
|
|
271
|
+
// count. A still-open day whose count keeps rising is the same record, not a new one, and
|
|
272
|
+
// the previous best named beside it is by construction a DIFFERENT day.
|
|
273
|
+
if (recordChanged(cur.maxDay, prev.maxDay)) {
|
|
274
|
+
items.push({ kind: "busiest_day", tool: name, detail: [cur.maxDayCalls, prev.maxDayCalls] });
|
|
275
|
+
}
|
|
276
|
+
// Independent of the day, on its own identity. Tying it to the day record instead was tried
|
|
277
|
+
// and reverted: it silenced a runaway minute on a day that set no record, and silenced it
|
|
278
|
+
// permanently, because the mark's `maxBurst` rises whether or not the line was ever shown.
|
|
279
|
+
if (recordChanged(cur.maxBurstAt, prev.maxBurstAt)) {
|
|
280
|
+
items.push({ kind: "burst", tool: name, detail: cur.maxBurst });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// 🔴 WHEN THE AGENT RAN IS A FACT ABOUT THE SESSION, SO IT IS DIFFED ONCE, OUTSIDE THE LOOP.
|
|
284
|
+
// These three used to be pushed per tool while carrying an empty tool name. Three tools first
|
|
285
|
+
// running at 03:00 on a Saturday produced three byte-identical "first call between 03:00 and
|
|
286
|
+
// 04:00" lines and three identical "first weekend call" lines, which filled the whole
|
|
287
|
+
// six-line budget with duplicates, pushed every real item behind "(N more not listed)", and
|
|
288
|
+
// inflated the session line's count to "9 things changed".
|
|
289
|
+
//
|
|
290
|
+
// ⚠️ AND NOT ON A FIRST LOOK, for the same reason a brand-new tool does not get its hours
|
|
291
|
+
// listed beside it: on a ledger we have never marked, "first call between 10:00 and 11:00" is
|
|
292
|
+
// not news, it is a restatement of "here is the ledger". The per-tool branch above already
|
|
293
|
+
// skips these for an unseen tool; without this guard the session-level pass reintroduced them
|
|
294
|
+
// for every tool at once, and the first-look screen grew two lines nobody needed.
|
|
295
|
+
if (previous) {
|
|
296
|
+
const prevHours = new Set(Object.values(prevTools).flatMap((t) => t.hours));
|
|
297
|
+
const curHours = new Set(Object.values(current.tools).flatMap((t) => t.hours));
|
|
298
|
+
for (const h of [...curHours].sort((a, b) => a - b)) {
|
|
299
|
+
if (!prevHours.has(h))
|
|
300
|
+
items.push({ kind: "hour", tool: "", detail: h });
|
|
301
|
+
}
|
|
302
|
+
const prevWeekend = Object.values(prevTools).some((t) => t.weekend);
|
|
303
|
+
const curWeekend = Object.values(current.tools).some((t) => t.weekend);
|
|
304
|
+
if (curWeekend && !prevWeekend)
|
|
305
|
+
items.push({ kind: "weekend", tool: "" });
|
|
306
|
+
}
|
|
307
|
+
// 🔴 A GAP IS A SILENCE, NOT A SPAN. This used to be `cur.lastTs - prev.lastTs`, which is the
|
|
308
|
+
// distance to the MOST RECENT call: an agent that ran every day for four days, read on Monday
|
|
309
|
+
// and again on Friday, was announced as "first call in 4 days". The silence is the distance
|
|
310
|
+
// from the last call the mark saw to the FIRST call after it, which needs the rows, not the
|
|
311
|
+
// totals. With no rows passed (a caller diffing two stored marks) the gap is simply not
|
|
312
|
+
// claimed, rather than claimed wrongly.
|
|
313
|
+
const prevLast = Math.max(0, ...Object.values(prevTools).map((t) => t.lastTs));
|
|
314
|
+
if (prevLast && rows.length) {
|
|
315
|
+
const next = rows.filter((r) => r.ts > prevLast).sort((a, b) => a.ts - b.ts)[0];
|
|
316
|
+
if (next && next.ts - prevLast >= MIN_GAP_MS) {
|
|
317
|
+
items.push({ kind: "gap", tool: "", detail: next.ts - prevLast });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Through the same rule as the other two records, though this one already compared the tool's
|
|
321
|
+
// NAME rather than its call count. Routed here so the three cannot drift apart again, which is
|
|
322
|
+
// what the Python entry point exists to prevent.
|
|
323
|
+
if (previous?.busiest && recordChanged(current.busiest, previous.busiest)) {
|
|
324
|
+
items.push({ kind: "busiest", tool: current.busiest });
|
|
325
|
+
}
|
|
326
|
+
return items;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The stored mark, RATCHETED: every maximum and every set only ever grows.
|
|
330
|
+
*
|
|
331
|
+
* 🔴 WITHOUT THIS, DECISION 1 AT THE TOP OF THIS FILE IS A COMMENT AND NOT A BEHAVIOUR.
|
|
332
|
+
* `diffNovelty` only ever reports a rise, but `advanceMark` used to store the freshly computed
|
|
333
|
+
* totals wholesale, and those are computed over the KEPT rows. So a trim moved the mark DOWN.
|
|
334
|
+
* Concretely: `chargeCard` runs at 1,000,000 and the mark records it; thirty-one days later that
|
|
335
|
+
* row is trimmed and the next read stores `maxAmount: 500`; a later call at 900 is then
|
|
336
|
+
* announced as "largest amount yet: ≥900". The same held for the surface set (a repeat "first
|
|
337
|
+
* call to your database"), the argument names, the busiest day and the burst.
|
|
338
|
+
*
|
|
339
|
+
* `busiest` is deliberately NOT ratcheted: "now your busiest tool" is a claim about the present,
|
|
340
|
+
* and a genuine change of leader is news each time it happens.
|
|
341
|
+
*
|
|
342
|
+
* ⚠️ AND IT IS UNBOUNDED, WHICH IS A KNOWN, DELIBERATE, WRITTEN-DOWN GAP. Before the ratchet the
|
|
343
|
+
* mark was recomputed from the kept rows, so the ledger's retention capped it as a side effect
|
|
344
|
+
* nobody had decided; merging removed that cap. A program naming tools dynamically, one per
|
|
345
|
+
* tenant or per resource, accumulates every name it has ever used.
|
|
346
|
+
*
|
|
347
|
+
* 🔴 AN EVICTION RULE WAS WRITTEN AND THEN REVERTED, AND THE REASON IS THE POINT. Dropping tools
|
|
348
|
+
* whose last call had aged out of the ledger reintroduced this function's own worked example: a
|
|
349
|
+
* `chargeCard` that runs monthly at 1,000,000 had its entry evicted on day 31 and, on day 32,
|
|
350
|
+
* was announced as "first time we have seen this tool" with "largest amount yet: ≥900". The
|
|
351
|
+
* justification, that a tool aged out of the ledger cannot be diffed anyway, was false: the
|
|
352
|
+
* STORED MARK is the other side of the diff, which is the entire reason totals are stored rather
|
|
353
|
+
* than read back off the rows. Any eviction keyed on time has this defect for any tool that runs
|
|
354
|
+
* less often than the window. A cap on the NUMBER of entries would not, and that is what the
|
|
355
|
+
* backlog row asks for. Growth is slow and hypothetical; the bug the eviction caused was
|
|
356
|
+
* immediate, so the gap is carried openly instead.
|
|
357
|
+
*/
|
|
358
|
+
function mergeMark(previous, current) {
|
|
359
|
+
if (!previous)
|
|
360
|
+
return current;
|
|
361
|
+
const union = (a, b) => [...new Set([...a, ...b])].sort();
|
|
362
|
+
const tools = { ...previous.tools };
|
|
363
|
+
for (const [name, cur] of Object.entries(current.tools)) {
|
|
364
|
+
const prev = tools[name];
|
|
365
|
+
if (!prev) {
|
|
366
|
+
tools[name] = cur;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
tools[name] = {
|
|
370
|
+
calls: Math.max(prev.calls, cur.calls),
|
|
371
|
+
args: union(prev.args, cur.args),
|
|
372
|
+
combos: union(prev.combos, cur.combos),
|
|
373
|
+
classes: union(prev.classes, cur.classes),
|
|
374
|
+
maxAmount: Math.max(prev.maxAmount, cur.maxAmount),
|
|
375
|
+
maxDayCalls: Math.max(prev.maxDayCalls, cur.maxDayCalls),
|
|
376
|
+
maxDay: cur.maxDayCalls > prev.maxDayCalls ? cur.maxDay : prev.maxDay,
|
|
377
|
+
maxBurst: Math.max(prev.maxBurst, cur.maxBurst),
|
|
378
|
+
maxBurstAt: cur.maxBurst > prev.maxBurst ? cur.maxBurstAt : prev.maxBurstAt,
|
|
379
|
+
hours: union(prev.hours, cur.hours).sort((a, b) => a - b),
|
|
380
|
+
weekend: prev.weekend || cur.weekend,
|
|
381
|
+
lastTs: Math.max(prev.lastTs, cur.lastTs),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
tools,
|
|
386
|
+
busiest: current.busiest || previous.busiest,
|
|
387
|
+
busiestCalls: Math.max(previous.busiestCalls, current.busiestCalls),
|
|
388
|
+
totalCalls: Math.max(previous.totalCalls, current.totalCalls),
|
|
389
|
+
distinctTools: Object.keys(tools).length,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/** The words for one item, in ONE place because two screens print them. "" for anything unrecognised. */
|
|
393
|
+
function formatNoveltyItem(item) {
|
|
394
|
+
const names = (v) => (Array.isArray(v) ? v.map(String) : []);
|
|
395
|
+
switch (item.kind) {
|
|
396
|
+
case "tool":
|
|
397
|
+
return "first time we have seen this tool";
|
|
398
|
+
case "surface": {
|
|
399
|
+
const word = SURFACE_WORDS[item.detail];
|
|
400
|
+
return word ? `first call to your ${word}` : "";
|
|
401
|
+
}
|
|
402
|
+
case "argument": {
|
|
403
|
+
const n = names(item.detail);
|
|
404
|
+
if (!n.length)
|
|
405
|
+
return "";
|
|
406
|
+
const shown = n.slice(0, MAX_ARGS_SHOWN).join(", ");
|
|
407
|
+
const rest = n.length - Math.min(n.length, MAX_ARGS_SHOWN);
|
|
408
|
+
return `called with ${shown}${rest > 0 ? ` and ${rest} more` : ""} for the first time`;
|
|
409
|
+
}
|
|
410
|
+
case "dropped": {
|
|
411
|
+
const n = names(item.detail);
|
|
412
|
+
if (!n.length)
|
|
413
|
+
return "";
|
|
414
|
+
return `called without ${n.slice(0, MAX_ARGS_SHOWN).join(", ")} for the first time`;
|
|
415
|
+
}
|
|
416
|
+
case "amount": {
|
|
417
|
+
// "amount", not "number": the column holds the magnitude of an argument the tool itself
|
|
418
|
+
// NAMED as an amount. And "yet", because the stored band is a lower bound.
|
|
419
|
+
const v = Number(item.detail ?? 0);
|
|
420
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
421
|
+
return "";
|
|
422
|
+
return `largest amount yet: ≥${Math.trunc(v).toLocaleString("en-US")}`;
|
|
423
|
+
}
|
|
424
|
+
case "busiest":
|
|
425
|
+
return "now your busiest tool";
|
|
426
|
+
case "busiest_day": {
|
|
427
|
+
const pair = Array.isArray(item.detail) ? item.detail : [];
|
|
428
|
+
const now = Number(pair[0] ?? 0);
|
|
429
|
+
const before = Number(pair[1] ?? 0);
|
|
430
|
+
if (!Number.isFinite(now) || now <= 0)
|
|
431
|
+
return "";
|
|
432
|
+
// The previous record is stated beside the new one: "45 calls" alone is a number the
|
|
433
|
+
// reader would have had to be keeping track of to find interesting.
|
|
434
|
+
// The previous best is honest here because the item only exists when the record changed
|
|
435
|
+
// HANDS, so the day it names is always a different one. `recordChanged` is what makes that
|
|
436
|
+
// true; without it this clause described a day that never existed.
|
|
437
|
+
const head = `busiest day yet: ${(0, report_1.plural)(now, "call")}`;
|
|
438
|
+
return before ? `${head} (previous best ${before})` : head;
|
|
439
|
+
}
|
|
440
|
+
case "burst": {
|
|
441
|
+
const v = Number(item.detail ?? 0);
|
|
442
|
+
return v > 0 ? `${(0, report_1.plural)(v, "call")} in one minute, the most yet` : "";
|
|
443
|
+
}
|
|
444
|
+
case "hour": {
|
|
445
|
+
const h = Number(item.detail);
|
|
446
|
+
if (!Number.isInteger(h))
|
|
447
|
+
return "";
|
|
448
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
449
|
+
// % 24 so the last hour of the day does not render "between 23:00 and 24:00", which is
|
|
450
|
+
// not a time anybody writes.
|
|
451
|
+
return `first call between ${p(h)}:00 and ${p((h + 1) % 24)}:00`;
|
|
452
|
+
}
|
|
453
|
+
case "weekend":
|
|
454
|
+
return "first weekend call";
|
|
455
|
+
case "gap": {
|
|
456
|
+
const days = Math.floor(Number(item.detail ?? 0) / 86400000);
|
|
457
|
+
return days >= 1 ? `first call in ${(0, report_1.plural)(days, "day")}` : "";
|
|
458
|
+
}
|
|
459
|
+
default:
|
|
460
|
+
return "";
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Most interesting first. A brand new tool outranks a new hour-of-day on the same screen. */
|
|
464
|
+
const KIND_RANK = {
|
|
465
|
+
tool: 0, surface: 1, amount: 2, busiest: 3, busiest_day: 4,
|
|
466
|
+
burst: 5, gap: 6, dropped: 7, argument: 8, weekend: 9, hour: 10,
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* The items that actually produce a line. `formatNoveltyItem` returns "" for a kind it cannot
|
|
470
|
+
* word (a surface with no plain noun, an empty argument list), so a raw `items.length` counts
|
|
471
|
+
* things no reader will ever see. Both screens count off THIS, so the session line's number and
|
|
472
|
+
* the report's "(N more not listed)" cannot disagree.
|
|
473
|
+
*/
|
|
474
|
+
function formattableItems(items) {
|
|
475
|
+
return items.filter((item) => formatNoveltyItem(item) !== "");
|
|
476
|
+
}
|
|
477
|
+
function topNovelty(items, limit) {
|
|
478
|
+
return [...items]
|
|
479
|
+
.sort((a, b) => KIND_RANK[a.kind] - KIND_RANK[b.kind] || a.tool.localeCompare(b.tool))
|
|
480
|
+
.slice(0, Math.max(0, limit));
|
|
481
|
+
}
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// The store.
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
exports.NOVELTY_FILE = ".agentx-novelty.json";
|
|
486
|
+
/** Beside the ledger, so moving the ledger moves the mark with it. */
|
|
487
|
+
function noveltyPath(env = process.env) {
|
|
488
|
+
return path.join(path.dirname((0, ledger_1.ledgerPath)(env)), exports.NOVELTY_FILE);
|
|
489
|
+
}
|
|
490
|
+
function loadMarkFile(file) {
|
|
491
|
+
try {
|
|
492
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
493
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// Missing, unreadable or corrupt all mean the same thing to a reader: we have no mark, so
|
|
497
|
+
// this is a first look. Never throws; novelty is a nicety and must not break `audit`.
|
|
498
|
+
return {};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function saveMarkFile(state, file) {
|
|
502
|
+
try {
|
|
503
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
504
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
505
|
+
fs.writeFileSync(tmp, JSON.stringify(state), "utf8");
|
|
506
|
+
fs.renameSync(tmp, file);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
/* a mark we could not store means the same novelty is offered again next time */
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* PURE READ. Works out what is new for one screen and stores nothing. `advanceMark` is the
|
|
514
|
+
* separate, deliberate second step. See decision 3.
|
|
515
|
+
*/
|
|
516
|
+
function readNovelty(mark, rows, file = noveltyPath()) {
|
|
517
|
+
const current = computeTotals(rows);
|
|
518
|
+
const previous = loadMarkFile(file).marks?.[mark] ?? null;
|
|
519
|
+
// Rows are passed through so the gap can be measured as a SILENCE. See the note in diffNovelty.
|
|
520
|
+
return { items: diffNovelty(previous, current, rows), firstLook: previous === null, current };
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Store the totals for one screen. Called only after the screen has actually been written.
|
|
524
|
+
*
|
|
525
|
+
* 🔴 MERGED INTO THE STORED MARK, NEVER WRITTEN OVER IT. `current` is computed from the rows the
|
|
526
|
+
* ledger still holds, so writing it wholesale moves the mark DOWN after a trim and re-announces
|
|
527
|
+
* an ordinary call as a record. See `mergeMark`.
|
|
528
|
+
*/
|
|
529
|
+
function advanceMark(mark, current, file = noveltyPath()) {
|
|
530
|
+
const state = loadMarkFile(file);
|
|
531
|
+
const previous = state.marks?.[mark] ?? null;
|
|
532
|
+
state.marks = { ...(state.marks ?? {}), [mark]: mergeMark(previous, current) };
|
|
533
|
+
saveMarkFile(state, file);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* The ONE line the session-end screen prints: how much is new, and where to read it. "" when
|
|
537
|
+
* there is nothing to say, so the caller cannot print a bare pointer to an empty report.
|
|
538
|
+
*
|
|
539
|
+
* A pure function on purpose. It runs from a process exit hook, where a test cannot see it, and
|
|
540
|
+
* a sentence is exactly the thing no test reddens unless it is reachable. This one is.
|
|
541
|
+
*/
|
|
542
|
+
function sessionNoveltyLine(read) {
|
|
543
|
+
const n = formattableItems(read.items).length;
|
|
544
|
+
if (!n)
|
|
545
|
+
return "";
|
|
546
|
+
// 🔴 THE PRONOUN COUNTS TOO. The number went through `plural` and "them" did not, so one item
|
|
547
|
+
// printed "1 thing changed since you last looked. That report names them." It read as a typo
|
|
548
|
+
// in the first sentence a developer sees. It was almost never visible while a still-open day
|
|
549
|
+
// re-announced itself every run and the count was always several; fixing that made n = 1 the
|
|
550
|
+
// ordinary case, which is how a latent sentence became the common one. Found in a founder walk.
|
|
551
|
+
const names = n === 1 ? "That report names it." : "That report names them.";
|
|
552
|
+
return read.firstLook
|
|
553
|
+
? ` First look at this ledger: ${(0, report_1.plural)(n, "thing")} to see. ${names}`
|
|
554
|
+
: ` ${(0, report_1.plural)(n, "thing")} changed since you last looked. ${names}`;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* The block both screens print, as lines. Empty array when there is nothing to say, so a caller
|
|
558
|
+
* cannot accidentally print a bare header over nothing.
|
|
559
|
+
*
|
|
560
|
+
* TWO HEADERS, because they are two different claims. On a ledger we have never marked, every
|
|
561
|
+
* tool in it is new to US, not new to the agent, and "first call to your database" under a
|
|
562
|
+
* "since you last looked" header would date a year of history to this afternoon.
|
|
563
|
+
*/
|
|
564
|
+
function renderNovelty(read, indent = " ") {
|
|
565
|
+
const all = formattableItems(read.items);
|
|
566
|
+
const shown = topNovelty(all, exports.MAX_NOVELTY_SHOWN)
|
|
567
|
+
.map((item) => ({ item, line: formatNoveltyItem(item) }));
|
|
568
|
+
if (!shown.length)
|
|
569
|
+
return [];
|
|
570
|
+
const out = [indent + (read.firstLook ? "FIRST LOOK AT THIS LEDGER" : "NEW SINCE YOU LAST LOOKED")];
|
|
571
|
+
for (const { item, line } of shown) {
|
|
572
|
+
// A blank tool name does NOT get the empty column. Padding it printed a sentence marooned a
|
|
573
|
+
// third of the way across the screen with nothing to its left; the column exists to line
|
|
574
|
+
// tool names up with each other, and a line with no tool name is not in that column.
|
|
575
|
+
out.push(item.tool ? `${indent} ${item.tool.padEnd(24)} ${line}` : `${indent} ${line}`);
|
|
576
|
+
}
|
|
577
|
+
const hidden = all.length - shown.length;
|
|
578
|
+
if (hidden > 0)
|
|
579
|
+
out.push(`${indent} (${hidden} more not listed)`);
|
|
580
|
+
return out;
|
|
581
|
+
}
|
package/dist/report.d.ts
CHANGED
|
@@ -60,8 +60,14 @@ export interface ToolSummary {
|
|
|
60
60
|
}
|
|
61
61
|
/** Group the rows by tool, busiest first. */
|
|
62
62
|
export declare function summariseTools(rows: readonly CallRow[]): ToolSummary[];
|
|
63
|
-
/**
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The grouped screen. Returns the text; the caller prints it.
|
|
65
|
+
*
|
|
66
|
+
* `novelty` is the already-rendered "what changed" block, passed IN rather than computed here.
|
|
67
|
+
* This module stays a pure formatter: reading the stored mark and moving it are side effects,
|
|
68
|
+
* and they belong with the caller that also decides whether a human is looking.
|
|
69
|
+
*/
|
|
70
|
+
export declare function renderTools(read: LedgerRead, opts?: ViewOptions, novelty?: readonly string[]): string;
|
|
65
71
|
/** The `--calls` screen: one line per call, newest first. The ORDER is the point. */
|
|
66
72
|
export declare function renderCalls(read: LedgerRead, opts?: ViewOptions): string;
|
|
67
73
|
/**
|