@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.
@@ -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
- /** The grouped screen. Returns the text; the caller prints it. */
64
- export declare function renderTools(read: LedgerRead, opts?: ViewOptions): string;
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
  /**