@lovelaces-io/storyteller 0.3.0 → 0.4.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/AGENTS.md +42 -1
- package/README.md +30 -0
- package/dist/index.cjs +454 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -515
- package/dist/index.d.ts +55 -515
- package/dist/index.js +440 -17
- package/dist/index.js.map +1 -1
- package/dist/store/file.cjs +513 -0
- package/dist/store/file.cjs.map +1 -0
- package/dist/store/file.d.cts +29 -0
- package/dist/store/file.d.ts +29 -0
- package/dist/store/file.js +478 -0
- package/dist/store/file.js.map +1 -0
- package/dist/stories-BgN4BSoZ.d.cts +744 -0
- package/dist/stories-BgN4BSoZ.d.ts +744 -0
- package/llms.txt +11 -1
- package/package.json +16 -9
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/store/file.ts
|
|
21
|
+
var file_exports = {};
|
|
22
|
+
__export(file_exports, {
|
|
23
|
+
StoryQueryBuilder: () => StoryQueryBuilder,
|
|
24
|
+
applyQuery: () => applyQuery,
|
|
25
|
+
canonicalRow: () => canonicalRow,
|
|
26
|
+
fileStore: () => fileStore,
|
|
27
|
+
matchesQuery: () => matchesQuery,
|
|
28
|
+
parseDuration: () => parseDuration,
|
|
29
|
+
stories: () => stories,
|
|
30
|
+
storySearchText: () => storySearchText,
|
|
31
|
+
toStoredStory: () => toStoredStory
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(file_exports);
|
|
34
|
+
|
|
35
|
+
// src/redaction.ts
|
|
36
|
+
var REDACTED = "[redacted]";
|
|
37
|
+
var RULE_GROUPS = [
|
|
38
|
+
{
|
|
39
|
+
// Vendor key formats
|
|
40
|
+
anchor: /sk[-_]|rk_|gh[opusr]_|github_pat_|glpat-|npm_|xox[abprs]-|AIza|SG\.|AKIA|ASIA|eyJ|PRIVATE KEY/,
|
|
41
|
+
rules: [
|
|
42
|
+
// Stripe secret and restricted keys
|
|
43
|
+
{ pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,}\b/g, replacement: REDACTED },
|
|
44
|
+
// OpenAI, Anthropic and similar `sk-` keys
|
|
45
|
+
{ pattern: /\bsk-(?:[A-Za-z0-9]+-)*[A-Za-z0-9_-]{16,}/g, replacement: REDACTED },
|
|
46
|
+
// GitHub tokens, classic and fine-grained
|
|
47
|
+
{ pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}\b/g, replacement: REDACTED },
|
|
48
|
+
{ pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: REDACTED },
|
|
49
|
+
// GitLab, npm, Slack, Google, SendGrid, AWS access key ids
|
|
50
|
+
{ pattern: /\bglpat-[A-Za-z0-9_-]{16,}\b/g, replacement: REDACTED },
|
|
51
|
+
{ pattern: /\bnpm_[A-Za-z0-9]{30,}\b/g, replacement: REDACTED },
|
|
52
|
+
{ pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, replacement: REDACTED },
|
|
53
|
+
{ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, replacement: REDACTED },
|
|
54
|
+
{ pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g, replacement: REDACTED },
|
|
55
|
+
{ pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: REDACTED },
|
|
56
|
+
// JSON Web Tokens
|
|
57
|
+
{ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, replacement: REDACTED },
|
|
58
|
+
// PEM private keys, whole block
|
|
59
|
+
{ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replacement: REDACTED }
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
// Authorization header values: keep the scheme, drop the credential
|
|
64
|
+
anchor: /\b(?:Bearer|Basic|Token|Digest) /,
|
|
65
|
+
rules: [{ pattern: /\b(Bearer|Basic|Token|Digest)\s+[A-Za-z0-9._~+/=-]{12,}/g, replacement: `$1 ${REDACTED}` }]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
// URLs: a password in the userinfo, a secret in the query string
|
|
69
|
+
anchor: /:\/\/|[?&]/,
|
|
70
|
+
rules: [
|
|
71
|
+
{ pattern: /(:\/\/[^\s/:@]+:)[^\s@/]+(@)/g, replacement: `$1${REDACTED}$2` },
|
|
72
|
+
{
|
|
73
|
+
pattern: /([?&](?:token|access_token|refresh_token|id_token|api_key|apikey|api-key|key|secret|client_secret|password|passwd|pwd|signature|sig|auth|authorization)=)[^&\s#]+/gi,
|
|
74
|
+
replacement: `$1${REDACTED}`
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
];
|
|
79
|
+
var STRONG_KEY_HINT = /password|passwd|passphrase|secret|apikey|privatekey|credential|authorization|accesskey|sessionid|cookie|refreshtoken|accesstoken|idtoken|clientkey|signingkey|encryptionkey|masterkey|servicekey/;
|
|
80
|
+
var WEAK_KEY_HINT = /token|key|auth|bearer|cert|pwd/;
|
|
81
|
+
var keyHints = /* @__PURE__ */ new Map();
|
|
82
|
+
var KEY_HINT_CACHE_LIMIT = 4096;
|
|
83
|
+
function hintFor(key) {
|
|
84
|
+
const cached = keyHints.get(key);
|
|
85
|
+
if (cached !== void 0) return cached;
|
|
86
|
+
const reduced = normalizeKeyForMatching(key);
|
|
87
|
+
const hint = STRONG_KEY_HINT.test(reduced) ? "strong" : WEAK_KEY_HINT.test(reduced) ? "weak" : "none";
|
|
88
|
+
if (keyHints.size >= KEY_HINT_CACHE_LIMIT) keyHints.clear();
|
|
89
|
+
keyHints.set(key, hint);
|
|
90
|
+
return hint;
|
|
91
|
+
}
|
|
92
|
+
var MIN_STRONG_LENGTH = 4;
|
|
93
|
+
var MIN_ENTROPY_LENGTH = 16;
|
|
94
|
+
var MIN_ENTROPY_BITS = 3;
|
|
95
|
+
var MIN_STRICT_LENGTH = 32;
|
|
96
|
+
function normalizeKeyForMatching(key) {
|
|
97
|
+
return key.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
function entropyOf(text) {
|
|
100
|
+
const counts = /* @__PURE__ */ new Map();
|
|
101
|
+
for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
102
|
+
let bits = 0;
|
|
103
|
+
for (const count of counts.values()) {
|
|
104
|
+
const p = count / text.length;
|
|
105
|
+
bits -= p * Math.log2(p);
|
|
106
|
+
}
|
|
107
|
+
return bits;
|
|
108
|
+
}
|
|
109
|
+
function looksRandom(value, minimumLength) {
|
|
110
|
+
if (value.length < minimumLength || /\s/.test(value)) return false;
|
|
111
|
+
if (/^[0-9a-f-]+$/i.test(value) || /^[0-9.-]+$/.test(value)) return false;
|
|
112
|
+
return entropyOf(value) >= MIN_ENTROPY_BITS;
|
|
113
|
+
}
|
|
114
|
+
function keyLooksSecret(key, value) {
|
|
115
|
+
if (typeof value !== "string") return false;
|
|
116
|
+
const hint = hintFor(key);
|
|
117
|
+
if (hint === "strong") return value.length >= MIN_STRONG_LENGTH;
|
|
118
|
+
if (hint === "weak") return looksRandom(value, MIN_ENTROPY_LENGTH);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
var ANY_ANCHOR = new RegExp(RULE_GROUPS.map((group) => group.anchor.source).join("|"));
|
|
122
|
+
function redactString(value, strictness = "balanced") {
|
|
123
|
+
if (strictness === "off" || value.length < 8) return value;
|
|
124
|
+
let result = value;
|
|
125
|
+
if (!ANY_ANCHOR.test(value)) return strictness === "strict" ? redactRandomRuns(value) : value;
|
|
126
|
+
for (const group of RULE_GROUPS) {
|
|
127
|
+
if (!group.anchor.test(result)) continue;
|
|
128
|
+
for (const { pattern, replacement } of group.rules) {
|
|
129
|
+
pattern.lastIndex = 0;
|
|
130
|
+
result = result.replace(pattern, replacement);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return strictness === "strict" ? redactRandomRuns(result) : result;
|
|
134
|
+
}
|
|
135
|
+
function redactRandomRuns(text) {
|
|
136
|
+
return text.replace(
|
|
137
|
+
/(?<![A-Za-z0-9+/=_-])[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/=_-])/g,
|
|
138
|
+
(run) => looksRandom(run, MIN_STRICT_LENGTH) && characterClasses(run) >= 3 ? REDACTED : run
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
function characterClasses(text) {
|
|
142
|
+
return [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((test) => test.test(text)).length;
|
|
143
|
+
}
|
|
144
|
+
function redactJson(value, options = {}) {
|
|
145
|
+
const strictness = options.strictness ?? "balanced";
|
|
146
|
+
const keys = options.redactKeys;
|
|
147
|
+
const walk = (node) => {
|
|
148
|
+
if (typeof node === "string") return redactString(node, strictness);
|
|
149
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
150
|
+
if (node && typeof node === "object") {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const [key, child] of Object.entries(node)) {
|
|
153
|
+
if (keys?.has(normalizeKeyForMatching(key)) || strictness !== "off" && keyLooksSecret(key, child)) {
|
|
154
|
+
out[key] = REDACTED;
|
|
155
|
+
} else {
|
|
156
|
+
out[key] = walk(child);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
return node;
|
|
162
|
+
};
|
|
163
|
+
return walk(value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/normalize.ts
|
|
167
|
+
var DEFAULT_REDACT_KEYS = [
|
|
168
|
+
"password",
|
|
169
|
+
"passphrase",
|
|
170
|
+
"token",
|
|
171
|
+
"secret",
|
|
172
|
+
"apiKey",
|
|
173
|
+
"accessKey",
|
|
174
|
+
"authorization",
|
|
175
|
+
"auth",
|
|
176
|
+
"cookie",
|
|
177
|
+
"sessionId",
|
|
178
|
+
"privateKey",
|
|
179
|
+
"clientSecret",
|
|
180
|
+
"refreshToken"
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
// src/environment.ts
|
|
184
|
+
var LEVEL_RANK = {
|
|
185
|
+
Information: 0,
|
|
186
|
+
Warning: 1,
|
|
187
|
+
Error: 2
|
|
188
|
+
};
|
|
189
|
+
var LEVEL_ALIASES = {
|
|
190
|
+
info: "Information",
|
|
191
|
+
information: "Information",
|
|
192
|
+
tell: "Information",
|
|
193
|
+
warn: "Warning",
|
|
194
|
+
warning: "Warning",
|
|
195
|
+
oops: "Error",
|
|
196
|
+
error: "Error"
|
|
197
|
+
};
|
|
198
|
+
function toStoryLevel(input) {
|
|
199
|
+
if (!input) return "Information";
|
|
200
|
+
return LEVEL_ALIASES[String(input).toLowerCase()] ?? "Information";
|
|
201
|
+
}
|
|
202
|
+
function meetsLevel(level, minimum) {
|
|
203
|
+
return LEVEL_RANK[level] >= LEVEL_RANK[minimum];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/store/storyStore.ts
|
|
207
|
+
var DEFAULT_KEY_SET = new Set(DEFAULT_REDACT_KEYS.map(normalizeKeyForMatching));
|
|
208
|
+
function toStoredStory(event, options = {}) {
|
|
209
|
+
const { timestamp, level, title, storyId, parentStoryId, origin, notes, durationMs, droppedEmissions, error } = event;
|
|
210
|
+
const strictness = options.redactValues ?? "balanced";
|
|
211
|
+
const scrub = (value) => strictness === "off" ? clone(value) : redactJson(clone(value), { redactKeys: DEFAULT_KEY_SET, strictness });
|
|
212
|
+
const record = {
|
|
213
|
+
timestamp,
|
|
214
|
+
level,
|
|
215
|
+
title: scrub(title),
|
|
216
|
+
storyId: storyId ?? generateStoryId(),
|
|
217
|
+
notes: scrub(notes)
|
|
218
|
+
};
|
|
219
|
+
if (parentStoryId !== void 0) record.parentStoryId = parentStoryId;
|
|
220
|
+
if (origin !== void 0) record.origin = scrub(origin);
|
|
221
|
+
if (durationMs !== void 0) record.durationMs = durationMs;
|
|
222
|
+
if (droppedEmissions !== void 0) record.droppedEmissions = droppedEmissions;
|
|
223
|
+
if (error !== void 0) record.error = scrub(error);
|
|
224
|
+
return record;
|
|
225
|
+
}
|
|
226
|
+
function clone(value) {
|
|
227
|
+
return JSON.parse(JSON.stringify(value));
|
|
228
|
+
}
|
|
229
|
+
function generateStoryId() {
|
|
230
|
+
const random = globalThis.crypto?.randomUUID;
|
|
231
|
+
if (random) return random.call(globalThis.crypto);
|
|
232
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
233
|
+
}
|
|
234
|
+
function storySearchText(story) {
|
|
235
|
+
const parts = [story.title];
|
|
236
|
+
for (const note of story.notes) {
|
|
237
|
+
parts.push(note.note);
|
|
238
|
+
for (const value of [note.who, note.what, note.where]) {
|
|
239
|
+
if (typeof value === "string") parts.push(value);
|
|
240
|
+
}
|
|
241
|
+
if (note.error?.message) parts.push(note.error.message);
|
|
242
|
+
}
|
|
243
|
+
if (story.error?.message) parts.push(story.error.message);
|
|
244
|
+
return parts.join("\n");
|
|
245
|
+
}
|
|
246
|
+
function flattenOrigin(origin) {
|
|
247
|
+
const flat = {};
|
|
248
|
+
if (!origin) return flat;
|
|
249
|
+
for (const key of ["who", "what", "where"]) {
|
|
250
|
+
const value = origin[key];
|
|
251
|
+
if (value === void 0 || value === null) continue;
|
|
252
|
+
flat[key] = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
253
|
+
}
|
|
254
|
+
return flat;
|
|
255
|
+
}
|
|
256
|
+
function canonicalRow(event) {
|
|
257
|
+
const story = toStoredStory(event);
|
|
258
|
+
const origin = flattenOrigin(story.origin);
|
|
259
|
+
return {
|
|
260
|
+
story_id: story.storyId,
|
|
261
|
+
parent_story_id: story.parentStoryId ?? null,
|
|
262
|
+
timestamp: story.timestamp,
|
|
263
|
+
level: story.level,
|
|
264
|
+
title: story.title,
|
|
265
|
+
origin_who: origin.who ?? null,
|
|
266
|
+
origin_what: origin.what ?? null,
|
|
267
|
+
origin_where: origin.where ?? null,
|
|
268
|
+
duration_ms: story.durationMs ?? null,
|
|
269
|
+
error_message: story.error?.message ?? null,
|
|
270
|
+
notes: JSON.stringify(story.notes),
|
|
271
|
+
search_text: storySearchText(story),
|
|
272
|
+
record: JSON.stringify(story)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function matchesQuery(story, query = {}) {
|
|
276
|
+
if (query.since && Date.parse(story.timestamp) < query.since.getTime()) return false;
|
|
277
|
+
if (query.until && Date.parse(story.timestamp) >= query.until.getTime()) return false;
|
|
278
|
+
if (query.level !== void 0) {
|
|
279
|
+
const levels = Array.isArray(query.level) ? query.level : [query.level];
|
|
280
|
+
if (!levels.includes(story.level)) return false;
|
|
281
|
+
}
|
|
282
|
+
if (query.minimumLevel && !meetsLevel(story.level, query.minimumLevel)) return false;
|
|
283
|
+
if (query.parentStoryId !== void 0 && story.parentStoryId !== query.parentStoryId) return false;
|
|
284
|
+
if (query.slowerThanMs !== void 0 && !((story.durationMs ?? -1) > query.slowerThanMs)) return false;
|
|
285
|
+
if (query.failed !== void 0) {
|
|
286
|
+
const failed = story.level === "Error" || story.error !== void 0;
|
|
287
|
+
if (failed !== query.failed) return false;
|
|
288
|
+
}
|
|
289
|
+
if (query.about) {
|
|
290
|
+
if (!storySearchText(story).toLowerCase().includes(query.about.toLowerCase())) return false;
|
|
291
|
+
}
|
|
292
|
+
if (query.from) {
|
|
293
|
+
const origin = Object.values(flattenOrigin(story.origin)).join("\n").toLowerCase();
|
|
294
|
+
if (!origin.includes(query.from.toLowerCase())) return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
function applyQuery(stories2, query = {}) {
|
|
299
|
+
const matched = [];
|
|
300
|
+
let position = 0;
|
|
301
|
+
for (const story of stories2) {
|
|
302
|
+
if (matchesQuery(story, query)) matched.push({ story, position });
|
|
303
|
+
position++;
|
|
304
|
+
}
|
|
305
|
+
const direction = query.order === "oldest" ? 1 : -1;
|
|
306
|
+
matched.sort((a, b) => {
|
|
307
|
+
const byTime = Date.parse(a.story.timestamp) - Date.parse(b.story.timestamp);
|
|
308
|
+
return (byTime !== 0 ? byTime : a.position - b.position) * direction;
|
|
309
|
+
});
|
|
310
|
+
const offset = Math.max(0, query.offset ?? 0);
|
|
311
|
+
const end = query.limit !== void 0 ? offset + Math.max(0, query.limit) : void 0;
|
|
312
|
+
return matched.slice(offset, end).map((entry) => entry.story);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/store/fileStore.ts
|
|
316
|
+
var import_promises = require("fs/promises");
|
|
317
|
+
var import_node_path = require("path");
|
|
318
|
+
function fileStore(path) {
|
|
319
|
+
let queue = Promise.resolve();
|
|
320
|
+
const serialized = (work) => {
|
|
321
|
+
const next = queue.then(work, work);
|
|
322
|
+
queue = next.catch(() => void 0);
|
|
323
|
+
return next;
|
|
324
|
+
};
|
|
325
|
+
async function readAll() {
|
|
326
|
+
let text;
|
|
327
|
+
try {
|
|
328
|
+
text = await (0, import_promises.readFile)(path, "utf8");
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (error.code === "ENOENT") return /* @__PURE__ */ new Map();
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
const stories2 = /* @__PURE__ */ new Map();
|
|
334
|
+
for (const line of text.split("\n")) {
|
|
335
|
+
if (!line.trim()) continue;
|
|
336
|
+
try {
|
|
337
|
+
const story = JSON.parse(line);
|
|
338
|
+
if (typeof story?.storyId === "string" && typeof story.timestamp === "string") {
|
|
339
|
+
stories2.delete(story.storyId);
|
|
340
|
+
stories2.set(story.storyId, story);
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return stories2;
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
path,
|
|
349
|
+
async flush() {
|
|
350
|
+
await queue;
|
|
351
|
+
},
|
|
352
|
+
async append(event) {
|
|
353
|
+
const line = `${JSON.stringify(toStoredStory(event))}
|
|
354
|
+
`;
|
|
355
|
+
await serialized(async () => {
|
|
356
|
+
await (0, import_promises.mkdir)((0, import_node_path.dirname)(path), { recursive: true });
|
|
357
|
+
await (0, import_promises.appendFile)(path, line, "utf8");
|
|
358
|
+
});
|
|
359
|
+
},
|
|
360
|
+
async get(storyId) {
|
|
361
|
+
return (await readAll()).get(storyId);
|
|
362
|
+
},
|
|
363
|
+
async query(criteria = {}) {
|
|
364
|
+
return applyQuery((await readAll()).values(), criteria);
|
|
365
|
+
},
|
|
366
|
+
async children(parentStoryId) {
|
|
367
|
+
return applyQuery((await readAll()).values(), { parentStoryId, order: "oldest" });
|
|
368
|
+
},
|
|
369
|
+
async prune(before) {
|
|
370
|
+
return serialized(async () => {
|
|
371
|
+
const stories2 = await readAll();
|
|
372
|
+
const boundary = before.getTime();
|
|
373
|
+
const kept = [];
|
|
374
|
+
let removed = 0;
|
|
375
|
+
for (const story of stories2.values()) {
|
|
376
|
+
if (Date.parse(story.timestamp) < boundary) removed++;
|
|
377
|
+
else kept.push(story);
|
|
378
|
+
}
|
|
379
|
+
if (removed === 0) return 0;
|
|
380
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
381
|
+
await (0, import_promises.writeFile)(temporary, kept.map((story) => JSON.stringify(story)).join("\n") + (kept.length ? "\n" : ""), "utf8");
|
|
382
|
+
await (0, import_promises.rename)(temporary, path);
|
|
383
|
+
return removed;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/store/stories.ts
|
|
390
|
+
var UNITS = {
|
|
391
|
+
ms: 1,
|
|
392
|
+
s: 1e3,
|
|
393
|
+
m: 6e4,
|
|
394
|
+
h: 36e5,
|
|
395
|
+
d: 864e5,
|
|
396
|
+
w: 6048e5
|
|
397
|
+
};
|
|
398
|
+
function parseDuration(input) {
|
|
399
|
+
if (typeof input === "number") {
|
|
400
|
+
if (!Number.isFinite(input) || input < 0) throw new RangeError(`Not a duration: ${input}`);
|
|
401
|
+
return input;
|
|
402
|
+
}
|
|
403
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)\s*$/i.exec(input);
|
|
404
|
+
if (!match) throw new RangeError(`Not a duration: "${input}" (expected e.g. "30s", "5m", "24h", "7d", "2w")`);
|
|
405
|
+
return Number(match[1]) * UNITS[match[2].toLowerCase()];
|
|
406
|
+
}
|
|
407
|
+
var StoryQueryBuilder = class _StoryQueryBuilder {
|
|
408
|
+
constructor(store, query, now) {
|
|
409
|
+
this.store = store;
|
|
410
|
+
this.query = query;
|
|
411
|
+
this.now = now;
|
|
412
|
+
}
|
|
413
|
+
with(patch) {
|
|
414
|
+
return new _StoryQueryBuilder(this.store, { ...this.query, ...patch }, this.now);
|
|
415
|
+
}
|
|
416
|
+
instant(input) {
|
|
417
|
+
return input instanceof Date ? input : new Date(this.now().getTime() - parseDuration(input));
|
|
418
|
+
}
|
|
419
|
+
/** Title, note text, scalar context or error message mentions this */
|
|
420
|
+
about(text) {
|
|
421
|
+
return this.with({ about: text });
|
|
422
|
+
}
|
|
423
|
+
/** The origin — who, what or where — mentions this */
|
|
424
|
+
from(origin) {
|
|
425
|
+
return this.with({ from: origin });
|
|
426
|
+
}
|
|
427
|
+
/** Exactly this level. Accepts the aliases: `"info"`, `"warn"`, `"oops"`. */
|
|
428
|
+
level(level) {
|
|
429
|
+
return this.with({ level: toStoryLevel(level) });
|
|
430
|
+
}
|
|
431
|
+
/** This level or worse */
|
|
432
|
+
atLeast(level) {
|
|
433
|
+
return this.with({ minimumLevel: toStoryLevel(level) });
|
|
434
|
+
}
|
|
435
|
+
/** Carried an error, or closed at Error level */
|
|
436
|
+
failing() {
|
|
437
|
+
return this.with({ failed: true });
|
|
438
|
+
}
|
|
439
|
+
/** Neither an error nor closed at Error level */
|
|
440
|
+
succeeding() {
|
|
441
|
+
return this.with({ failed: false });
|
|
442
|
+
}
|
|
443
|
+
/** Took longer than this: `"5s"`, `"2m"`, or milliseconds */
|
|
444
|
+
slowerThan(duration) {
|
|
445
|
+
return this.with({ slowerThanMs: parseDuration(duration) });
|
|
446
|
+
}
|
|
447
|
+
/** Began within this long ago (`"24h"`), or at or after this date */
|
|
448
|
+
since(when) {
|
|
449
|
+
return this.with({ since: this.instant(when) });
|
|
450
|
+
}
|
|
451
|
+
/** Began before this long ago, or before this date */
|
|
452
|
+
until(when) {
|
|
453
|
+
return this.with({ until: this.instant(when) });
|
|
454
|
+
}
|
|
455
|
+
/** The chapters of this story */
|
|
456
|
+
under(parentStoryId) {
|
|
457
|
+
return this.with({ parentStoryId });
|
|
458
|
+
}
|
|
459
|
+
/** Most recent first — the default */
|
|
460
|
+
newest() {
|
|
461
|
+
return this.with({ order: "newest" });
|
|
462
|
+
}
|
|
463
|
+
/** Earliest first */
|
|
464
|
+
oldest() {
|
|
465
|
+
return this.with({ order: "oldest" });
|
|
466
|
+
}
|
|
467
|
+
/** At most this many */
|
|
468
|
+
limit(count) {
|
|
469
|
+
return this.with({ limit: count });
|
|
470
|
+
}
|
|
471
|
+
/** Skip this many first */
|
|
472
|
+
skip(count) {
|
|
473
|
+
return this.with({ offset: count });
|
|
474
|
+
}
|
|
475
|
+
/** The structured criteria this question compiles to — what an adapter receives */
|
|
476
|
+
toQuery() {
|
|
477
|
+
return { ...this.query };
|
|
478
|
+
}
|
|
479
|
+
/** Every matching story */
|
|
480
|
+
all() {
|
|
481
|
+
return this.store.query(this.toQuery());
|
|
482
|
+
}
|
|
483
|
+
/** The first match, or nothing */
|
|
484
|
+
async first() {
|
|
485
|
+
const [story] = await this.store.query({ ...this.query, limit: 1 });
|
|
486
|
+
return story;
|
|
487
|
+
}
|
|
488
|
+
/** How many match, ignoring paging */
|
|
489
|
+
async count() {
|
|
490
|
+
const { limit: _limit, offset: _offset, ...unpaged } = this.query;
|
|
491
|
+
return (await this.store.query(unpaged)).length;
|
|
492
|
+
}
|
|
493
|
+
/** An awaited builder resolves to `.all()` */
|
|
494
|
+
then(onFulfilled, onRejected) {
|
|
495
|
+
return this.all().then(onFulfilled, onRejected);
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
function stories(store, options = {}) {
|
|
499
|
+
return new StoryQueryBuilder(store, {}, options.now ?? (() => /* @__PURE__ */ new Date()));
|
|
500
|
+
}
|
|
501
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
502
|
+
0 && (module.exports = {
|
|
503
|
+
StoryQueryBuilder,
|
|
504
|
+
applyQuery,
|
|
505
|
+
canonicalRow,
|
|
506
|
+
fileStore,
|
|
507
|
+
matchesQuery,
|
|
508
|
+
parseDuration,
|
|
509
|
+
stories,
|
|
510
|
+
storySearchText,
|
|
511
|
+
toStoredStory
|
|
512
|
+
});
|
|
513
|
+
//# sourceMappingURL=file.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/store/file.ts","../../src/redaction.ts","../../src/normalize.ts","../../src/environment.ts","../../src/store/storyStore.ts","../../src/store/fileStore.ts","../../src/store/stories.ts"],"sourcesContent":["/**\n * Entry point: @lovelaces-io/storyteller/store/file\n * Node only. The root import stays free of Node builtins.\n */\nexport type { CanonicalRow, StoredStory, StoryQuery, StoryStore, ToStoredStoryOptions } from \"./storyStore\";\nexport { applyQuery, canonicalRow, matchesQuery, storySearchText, toStoredStory } from \"./storyStore\";\nexport type { FileStore } from \"./fileStore\";\nexport { fileStore } from \"./fileStore\";\nexport type { DurationInput, StoriesOptions } from \"./stories\";\nexport { parseDuration, stories, StoryQueryBuilder } from \"./stories\";\n","/**\n * Redaction by value, not only by key.\n *\n * Key-name matching catches `password: \"hunter2\"`. It does not catch a Stripe\n * key inside an error message, a bearer token in a URL, or a private key\n * pasted into a note. Once stories are persisted and read back by agents, a\n * leaked secret is durable and retrievable, so the value itself has to be\n * recognised too.\n *\n * Defense in depth, not a guarantee: these are recognisable formats and\n * suspicious positions. A secret that looks like an ordinary word passes.\n * The balance leans toward keeping records readable — a viewer full of\n * `[redacted]` is no viewer — with a strict mode for the other trade.\n */\nimport type { JsonValue } from \"./normalize\";\n\n/** Marker written in place of a redacted value or span */\nexport const REDACTED = \"[redacted]\";\n\n/** How hard to look inside values. `off` leaves key matching only. */\nexport type RedactionStrictness = \"off\" | \"balanced\" | \"strict\";\n\n/**\n * Formats that are secrets by construction. Each pattern replaces only the\n * secret span, so the text around it — the sentence, the URL's host — stays.\n */\ntype Rule = { pattern: RegExp; replacement: string };\ntype RuleGroup = { anchor: RegExp; rules: Rule[] };\n\n/**\n * Grouped by what a string must contain for any rule in the group to match,\n * so one cheap anchor test per group decides whether its rules run. The\n * ordinary string — a sentence, a path, a UUID, a user agent — costs three\n * scans and no replacements.\n */\nconst RULE_GROUPS: RuleGroup[] = [\n {\n // Vendor key formats\n anchor: /sk[-_]|rk_|gh[opusr]_|github_pat_|glpat-|npm_|xox[abprs]-|AIza|SG\\.|AKIA|ASIA|eyJ|PRIVATE KEY/,\n rules: [\n // Stripe secret and restricted keys\n { pattern: /\\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,}\\b/g, replacement: REDACTED },\n // OpenAI, Anthropic and similar `sk-` keys\n { pattern: /\\bsk-(?:[A-Za-z0-9]+-)*[A-Za-z0-9_-]{16,}/g, replacement: REDACTED },\n // GitHub tokens, classic and fine-grained\n { pattern: /\\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}\\b/g, replacement: REDACTED },\n { pattern: /\\bgithub_pat_[A-Za-z0-9_]{20,}\\b/g, replacement: REDACTED },\n // GitLab, npm, Slack, Google, SendGrid, AWS access key ids\n { pattern: /\\bglpat-[A-Za-z0-9_-]{16,}\\b/g, replacement: REDACTED },\n { pattern: /\\bnpm_[A-Za-z0-9]{30,}\\b/g, replacement: REDACTED },\n { pattern: /\\bxox[abprs]-[A-Za-z0-9-]{10,}\\b/g, replacement: REDACTED },\n { pattern: /\\bAIza[0-9A-Za-z_-]{35}\\b/g, replacement: REDACTED },\n { pattern: /\\bSG\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}\\b/g, replacement: REDACTED },\n { pattern: /\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/g, replacement: REDACTED },\n // JSON Web Tokens\n { pattern: /\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/g, replacement: REDACTED },\n // PEM private keys, whole block\n { pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replacement: REDACTED },\n ],\n },\n {\n // Authorization header values: keep the scheme, drop the credential\n anchor: /\\b(?:Bearer|Basic|Token|Digest) /,\n rules: [{ pattern: /\\b(Bearer|Basic|Token|Digest)\\s+[A-Za-z0-9._~+/=-]{12,}/g, replacement: `$1 ${REDACTED}` }],\n },\n {\n // URLs: a password in the userinfo, a secret in the query string\n anchor: /:\\/\\/|[?&]/,\n rules: [\n { pattern: /(:\\/\\/[^\\s/:@]+:)[^\\s@/]+(@)/g, replacement: `$1${REDACTED}$2` },\n {\n pattern: /([?&](?:token|access_token|refresh_token|id_token|api_key|apikey|api-key|key|secret|client_secret|password|passwd|pwd|signature|sig|auth|authorization)=)[^&\\s#]+/gi,\n replacement: `$1${REDACTED}`,\n },\n ],\n },\n];\n\n/**\n * Key names whose string values are secrets whenever they are long enough to\n * be one. Substring match on the reduced key, so `dbPassword`, `x-api-key`,\n * `STRIPE_SECRET_KEY` and `clientCredential` all count.\n */\nconst STRONG_KEY_HINT =\n /password|passwd|passphrase|secret|apikey|privatekey|credential|authorization|accesskey|sessionid|cookie|refreshtoken|accesstoken|idtoken|clientkey|signingkey|encryptionkey|masterkey|servicekey/;\n/** Weaker hints: only redact when the value also looks random */\nconst WEAK_KEY_HINT = /token|key|auth|bearer|cert|pwd/;\n\ntype KeyHint = \"strong\" | \"weak\" | \"none\";\n/** Property names repeat across every note of every story; classify each once */\nconst keyHints = new Map<string, KeyHint>();\nconst KEY_HINT_CACHE_LIMIT = 4096;\n\nfunction hintFor(key: string): KeyHint {\n const cached = keyHints.get(key);\n if (cached !== undefined) return cached;\n const reduced = normalizeKeyForMatching(key);\n const hint: KeyHint = STRONG_KEY_HINT.test(reduced) ? \"strong\" : WEAK_KEY_HINT.test(reduced) ? \"weak\" : \"none\";\n if (keyHints.size >= KEY_HINT_CACHE_LIMIT) keyHints.clear();\n keyHints.set(key, hint);\n return hint;\n}\n\nconst MIN_STRONG_LENGTH = 4;\nconst MIN_ENTROPY_LENGTH = 16;\nconst MIN_ENTROPY_BITS = 3;\nconst MIN_STRICT_LENGTH = 32;\n\n/** Reduce a property name to letters and digits so casing and separators do not matter */\nexport function normalizeKeyForMatching(key: string): string {\n return key.replace(/[^a-zA-Z0-9]/g, \"\").toLowerCase();\n}\n\n/** Shannon entropy in bits per character */\nfunction entropyOf(text: string): number {\n const counts = new Map<string, number>();\n for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1);\n let bits = 0;\n for (const count of counts.values()) {\n const p = count / text.length;\n bits -= p * Math.log2(p);\n }\n return bits;\n}\n\n/** A string that reads as random: long enough, no whitespace, mixed characters */\nfunction looksRandom(value: string, minimumLength: number): boolean {\n if (value.length < minimumLength || /\\s/.test(value)) return false;\n // Hex is a hash or an id far more often than a secret; digits alone are a number\n if (/^[0-9a-f-]+$/i.test(value) || /^[0-9.-]+$/.test(value)) return false;\n return entropyOf(value) >= MIN_ENTROPY_BITS;\n}\n\n/**\n * Does this property hold a secret, judging by its name and what the value\n * looks like? Only strings — a `tokenCount: 12` or `tokens: [\"a\", \"b\"]` is data.\n */\nexport function keyLooksSecret(key: string, value: unknown): boolean {\n if (typeof value !== \"string\") return false;\n const hint = hintFor(key);\n if (hint === \"strong\") return value.length >= MIN_STRONG_LENGTH;\n if (hint === \"weak\") return looksRandom(value, MIN_ENTROPY_LENGTH);\n return false;\n}\n\n/**\n * Replace every recognisable secret inside a string, keeping the rest.\n * In strict mode any long random-looking run goes too.\n */\n/** Any group's anchor, as one scan: the ordinary string pays for this test and nothing else */\nconst ANY_ANCHOR = new RegExp(RULE_GROUPS.map((group) => group.anchor.source).join(\"|\"));\n\nexport function redactString(value: string, strictness: RedactionStrictness = \"balanced\"): string {\n if (strictness === \"off\" || value.length < 8) return value;\n let result = value;\n if (!ANY_ANCHOR.test(value)) return strictness === \"strict\" ? redactRandomRuns(value) : value;\n for (const group of RULE_GROUPS) {\n if (!group.anchor.test(result)) continue;\n for (const { pattern, replacement } of group.rules) {\n pattern.lastIndex = 0;\n result = result.replace(pattern, replacement);\n }\n }\n return strictness === \"strict\" ? redactRandomRuns(result) : result;\n}\n\n/** Strict mode: any long run that reads as random goes, hashes and ids excepted */\nfunction redactRandomRuns(text: string): string {\n return text.replace(/(?<![A-Za-z0-9+/=_-])[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/=_-])/g, (run) =>\n looksRandom(run, MIN_STRICT_LENGTH) && characterClasses(run) >= 3 ? REDACTED : run\n );\n}\n\nfunction characterClasses(text: string): number {\n return [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((test) => test.test(text)).length;\n}\n\nexport type RedactJsonOptions = {\n /** Property names redacted whatever their value; reduced like the keys they match */\n redactKeys?: Set<string>;\n strictness?: RedactionStrictness;\n};\n\n/**\n * Redact an already-normalized value: exact keys, secret-shaped keys, and\n * secret-shaped strings. This is the pass at the storage boundary, so a story\n * fed to a store by hand — or normalized with redaction off — is still covered\n * before it becomes durable. Returns a new value; the input is untouched.\n */\nexport function redactJson(value: JsonValue, options: RedactJsonOptions = {}): JsonValue {\n const strictness = options.strictness ?? \"balanced\";\n const keys = options.redactKeys;\n const walk = (node: JsonValue): JsonValue => {\n if (typeof node === \"string\") return redactString(node, strictness);\n if (Array.isArray(node)) return node.map(walk);\n if (node && typeof node === \"object\") {\n const out: { [key: string]: JsonValue } = {};\n for (const [key, child] of Object.entries(node)) {\n if (keys?.has(normalizeKeyForMatching(key)) || (strictness !== \"off\" && keyLooksSecret(key, child))) {\n out[key] = REDACTED;\n } else {\n out[key] = walk(child);\n }\n }\n return out;\n }\n return node;\n };\n return walk(value);\n}\n\nexport type RedactionFinding = {\n /** Where, as `$.a.b[0]` */\n path: string;\n /** Why it would be redacted */\n reason: \"key\" | \"key-hint\" | \"pattern\" | \"entropy\";\n /** The first characters, so a person can tell what it was without seeing it */\n preview: string;\n};\n\n/**\n * Report what redaction *would* do to a normalized value, changing nothing.\n * Run it over a corpus of real stories to see coverage rather than trust it —\n * and to find the false positives before they eat a record.\n */\nexport function auditRedaction(value: JsonValue, options: RedactJsonOptions = {}): RedactionFinding[] {\n const strictness = options.strictness ?? \"balanced\";\n const keys = options.redactKeys;\n const findings: RedactionFinding[] = [];\n const preview = (text: string) => (text.length <= 4 ? \"…\" : `${text.slice(0, 4)}…`);\n const walk = (node: JsonValue, path: string): void => {\n if (typeof node === \"string\") {\n if (node === REDACTED) return;\n const redacted = redactString(node, strictness);\n if (redacted !== node) {\n const strict = redactString(node, \"balanced\") === node;\n findings.push({ path, reason: strict ? \"entropy\" : \"pattern\", preview: preview(node) });\n }\n return;\n }\n if (Array.isArray(node)) {\n node.forEach((child, index) => walk(child, `${path}[${index}]`));\n return;\n }\n if (node && typeof node === \"object\") {\n for (const [key, child] of Object.entries(node)) {\n const childPath = `${path}.${key}`;\n if (child === REDACTED) continue;\n if (keys?.has(normalizeKeyForMatching(key))) {\n findings.push({ path: childPath, reason: \"key\", preview: typeof child === \"string\" ? preview(child) : \"…\" });\n } else if (strictness !== \"off\" && keyLooksSecret(key, child)) {\n findings.push({ path: childPath, reason: \"key-hint\", preview: preview(child as string) });\n } else {\n walk(child, childPath);\n }\n }\n }\n };\n walk(value, \"$\");\n return findings;\n}\n","import type { StoryError } from \"./storyteller\";\n\n/** A value that survives JSON.stringify with no loss and no throwing */\nexport type JsonValue =\n | string\n | number\n | boolean\n | null\n | JsonValue[]\n | { [key: string]: JsonValue };\n\nexport type NormalizeOptions = {\n /** How many levels deep to descend before replacing the value with a truncation marker */\n maxDepth?: number;\n /** How many array entries to keep before truncating */\n maxArrayLength?: number;\n /** How many object properties to keep before truncating */\n maxProperties?: number;\n /** How many characters of a string to keep before truncating */\n maxStringLength?: number;\n /** Property names whose values are replaced with the redaction marker */\n redactKeys?: string[];\n /** Set false to keep secret-shaped values as-is */\n redact?: boolean;\n /**\n * How hard to look inside values for secrets: `balanced` (default) redacts\n * recognisable token formats and random-looking strings under secret-shaped\n * keys; `strict` also redacts any long random-looking run; `off` matches key\n * names only.\n */\n redactValues?: RedactionStrictness;\n};\n\nimport { REDACTED, keyLooksSecret, normalizeKeyForMatching, redactString, type RedactionStrictness } from \"./redaction\";\nexport { REDACTED } from \"./redaction\";\n\n/**\n * Property names whose values are replaced with {@link REDACTED}.\n * Matching ignores case and separators, so `apiKey`, `api_key` and `API-KEY` all match.\n */\nexport const DEFAULT_REDACT_KEYS = [\n \"password\",\n \"passphrase\",\n \"token\",\n \"secret\",\n \"apiKey\",\n \"accessKey\",\n \"authorization\",\n \"auth\",\n \"cookie\",\n \"sessionId\",\n \"privateKey\",\n \"clientSecret\",\n \"refreshToken\",\n];\n\nconst DEFAULT_MAX_DEPTH = 6;\nconst DEFAULT_MAX_ARRAY_LENGTH = 100;\nconst DEFAULT_MAX_PROPERTIES = 100;\nconst DEFAULT_MAX_STRING_LENGTH = 8000;\n\n/** How far to follow an error's `cause` chain before stopping */\nconst MAX_CAUSE_DEPTH = 5;\n/** How many bytes of a binary value to include as a readable preview */\nconst BINARY_PREVIEW_BYTES = 16;\n\ntype ResolvedOptions = {\n maxDepth: number;\n maxArrayLength: number;\n maxProperties: number;\n maxStringLength: number;\n redactKeys: Set<string>;\n redact: boolean;\n redactValues: RedactionStrictness;\n};\n\n/**\n * Convert any value into a JSON-safe structure suitable for a story record.\n *\n * Handles the shapes real code actually holds — errors, dates, maps, sets, class\n * instances, binary buffers, circular references, throwing getters — and never throws,\n * so a hostile object logged by a caller cannot break the delivery pipeline.\n *\n * Data dropped for size is replaced with an explicit `@truncated` marker rather than\n * disappearing silently, so a consumer can tell the difference between \"this was empty\"\n * and \"this was too big\".\n *\n * @param input - Any value\n * @param options - Depth, size and redaction limits\n * @returns A value that JSON.stringify can always serialize\n *\n * @example\n * ```ts\n * normalizeValue({ user: new Map([[\"id\", 1]]), apiKey: \"sk-live-abc\" });\n * // { user: { \"@type\": \"Map\", entries: { id: 1 } }, apiKey: \"[redacted]\" }\n * ```\n */\nexport function normalizeValue(\n input: unknown,\n options: NormalizeOptions = {}\n): JsonValue {\n const resolved: ResolvedOptions = {\n maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,\n maxArrayLength: options.maxArrayLength ?? DEFAULT_MAX_ARRAY_LENGTH,\n maxProperties: options.maxProperties ?? DEFAULT_MAX_PROPERTIES,\n maxStringLength: options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH,\n redactKeys: new Set(\n (options.redactKeys ?? DEFAULT_REDACT_KEYS).map(normalizeKeyForMatching)\n ),\n redact: options.redact ?? true,\n redactValues: options.redactValues ?? \"balanced\",\n };\n\n try {\n return normalizeUnknown(input, resolved, 0, \"$\", new Map());\n } catch (failure) {\n // The normalizer must never throw into the delivery pipeline\n return `[Unreadable: ${describeFailure(failure)}]`;\n }\n}\n\n/**\n * Convert an unknown thrown value into a serializable StoryError,\n * following the `cause` chain and collecting AggregateError members.\n *\n * @param rawError - Any thrown or rejected value\n * @param options - Depth, size and redaction limits applied to attached data\n * @returns A StoryError safe to store and serialize\n */\nexport function normalizeError(\n rawError: unknown,\n options: NormalizeOptions = {}\n): StoryError {\n const resolved: ResolvedOptions = {\n maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,\n maxArrayLength: options.maxArrayLength ?? DEFAULT_MAX_ARRAY_LENGTH,\n maxProperties: options.maxProperties ?? DEFAULT_MAX_PROPERTIES,\n maxStringLength: options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH,\n redactKeys: new Set(\n (options.redactKeys ?? DEFAULT_REDACT_KEYS).map(normalizeKeyForMatching)\n ),\n redact: options.redact ?? true,\n redactValues: options.redactValues ?? \"balanced\",\n };\n\n return normalizeErrorInternal(rawError, resolved, 0);\n}\n\n/** Build a StoryError from any thrown value, bounded by the cause-chain depth */\nfunction normalizeErrorInternal(\n rawError: unknown,\n options: ResolvedOptions,\n causeDepth: number\n): StoryError {\n if (!(rawError instanceof Error)) {\n if (isPlainRecord(rawError)) {\n // Error-shaped objects from across a serialization boundary are common\n const record = rawError as Record<string, unknown>;\n const message = typeof record[\"message\"] === \"string\" ? redactText(record[\"message\"], options) : undefined;\n const name = typeof record[\"name\"] === \"string\" ? record[\"name\"] : undefined;\n if (message !== undefined || name !== undefined) {\n return {\n ...(name !== undefined ? { name } : {}),\n ...(message !== undefined ? { message } : {}),\n };\n }\n }\n return { message: redactText(safeStringify(rawError, options.maxStringLength), options) };\n }\n\n const normalized: StoryError = {\n name: rawError.name,\n message: redactText(rawError.message, options),\n };\n\n if (rawError.stack !== undefined) {\n normalized.stack = truncateString(redactText(rawError.stack, options), options.maxStringLength);\n }\n\n const cause = (rawError as { cause?: unknown }).cause;\n if (cause !== undefined) {\n if (causeDepth >= MAX_CAUSE_DEPTH) {\n normalized.cause = { \"@truncated\": { kind: \"causeChain\" } };\n } else if (cause instanceof Error) {\n normalized.cause = normalizeErrorInternal(cause, options, causeDepth + 1);\n } else {\n normalized.cause = normalizeUnknown(cause, options, 0, \"$.cause\", new Map());\n }\n }\n\n const aggregated = (rawError as { errors?: unknown }).errors;\n if (Array.isArray(aggregated)) {\n normalized.errors = aggregated\n .slice(0, options.maxArrayLength)\n .map((member) => normalizeErrorInternal(member, options, causeDepth + 1));\n }\n\n return normalized;\n}\n\n/** Recursively convert a value, tracking ancestors so cycles become readable markers */\nfunction normalizeUnknown(\n value: unknown,\n options: ResolvedOptions,\n depth: number,\n path: string,\n ancestors: Map<object, string>\n): JsonValue {\n if (value === null) return null;\n\n const valueType = typeof value;\n\n if (valueType === \"string\") {\n return truncateString(redactText(value as string, options), options.maxStringLength);\n }\n\n if (valueType === \"number\") {\n // NaN and Infinity are not representable in JSON\n return Number.isFinite(value as number) ? (value as number) : String(value);\n }\n\n if (valueType === \"boolean\") return value as boolean;\n if (valueType === \"undefined\") return null;\n if (valueType === \"bigint\") return `${String(value)}n`;\n if (valueType === \"symbol\") return String(value as symbol);\n\n if (valueType === \"function\") {\n const name = (value as { name?: string }).name;\n return `[Function: ${name ? name : \"anonymous\"}]`;\n }\n\n const objectValue = value as object;\n\n const existingPath = ancestors.get(objectValue);\n if (existingPath !== undefined) {\n return `[Circular → ${existingPath}]`;\n }\n\n if (depth > options.maxDepth) {\n return { \"@truncated\": { kind: \"depth\", depth: options.maxDepth } };\n }\n\n const wellKnown = normalizeWellKnown(objectValue, options, depth, path, ancestors);\n if (wellKnown !== undefined) return wellKnown;\n\n ancestors.set(objectValue, path);\n try {\n if (Array.isArray(objectValue)) {\n return normalizeArray(objectValue, options, depth, path, ancestors);\n }\n return normalizeObject(objectValue, options, depth, path, ancestors);\n } finally {\n // Only ancestors count as cycles — the same object appearing twice in a\n // tree is repetition, not recursion, and should render both times\n ancestors.delete(objectValue);\n }\n}\n\n/**\n * Convert the built-in object types that need a dedicated shape.\n * Returns undefined when the value is an ordinary array or object.\n */\nfunction normalizeWellKnown(\n value: object,\n options: ResolvedOptions,\n depth: number,\n path: string,\n ancestors: Map<object, string>\n): JsonValue | undefined {\n if (value instanceof Error) {\n return normalizeErrorInternal(value, options, 0) as unknown as JsonValue;\n }\n\n if (value instanceof Date) {\n const time = value.getTime();\n return Number.isNaN(time) ? \"[Invalid Date]\" : value.toISOString();\n }\n\n if (value instanceof RegExp) return String(value);\n if (value instanceof URL) return value.href;\n\n if (value instanceof Map) {\n const entries: Record<string, JsonValue> = {};\n let index = 0;\n let omitted = 0;\n for (const [entryKey, entryValue] of value) {\n if (index >= options.maxProperties) {\n omitted += 1;\n continue;\n }\n const keyLabel = safeStringify(entryKey, options.maxStringLength);\n entries[keyLabel] = redactOrNormalize(\n keyLabel,\n entryValue,\n options,\n depth + 1,\n `${path}.${keyLabel}`,\n ancestors\n );\n index += 1;\n }\n return {\n \"@type\": \"Map\",\n entries,\n ...(omitted ? { \"@truncated\": { kind: \"mapEntries\", omitted } } : {}),\n };\n }\n\n if (value instanceof Set) {\n const values: JsonValue[] = [];\n let omitted = 0;\n for (const member of value) {\n if (values.length >= options.maxArrayLength) {\n omitted += 1;\n continue;\n }\n values.push(\n normalizeUnknown(member, options, depth + 1, `${path}[${values.length}]`, ancestors)\n );\n }\n return {\n \"@type\": \"Set\",\n values,\n ...(omitted ? { \"@truncated\": { kind: \"setValues\", omitted } } : {}),\n };\n }\n\n if (value instanceof WeakMap) return \"[WeakMap]\";\n if (value instanceof WeakSet) return \"[WeakSet]\";\n if (value instanceof Promise) return \"[Promise]\";\n\n if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {\n return describeBinary(value);\n }\n\n const converted = callToJson(value);\n if (converted !== undefined) {\n return normalizeUnknown(converted, options, depth, path, ancestors);\n }\n\n return undefined;\n}\n\n/** Call a value's toJSON() if it has one, returning undefined when it has none or it throws */\nfunction callToJson(value: object): unknown {\n let toJson: unknown;\n try {\n toJson = (value as { toJSON?: unknown }).toJSON;\n } catch {\n return undefined;\n }\n\n if (typeof toJson !== \"function\") return undefined;\n\n try {\n return (toJson as () => unknown).call(value);\n } catch (failure) {\n return `[Unreadable: ${describeFailure(failure)}]`;\n }\n}\n\n/** Describe a binary value by its size and leading bytes rather than dumping its contents */\nfunction describeBinary(value: ArrayBufferView | ArrayBuffer): JsonValue {\n const typeName = readConstructorName(value) ?? \"ArrayBuffer\";\n const byteLength = value.byteLength;\n\n let preview: string;\n try {\n const bytes =\n value instanceof ArrayBuffer\n ? new Uint8Array(value, 0, Math.min(BINARY_PREVIEW_BYTES, byteLength))\n : new Uint8Array(\n value.buffer,\n value.byteOffset,\n Math.min(BINARY_PREVIEW_BYTES, value.byteLength)\n );\n preview = [...bytes].map((byte) => byte.toString(16).padStart(2, \"0\")).join(\" \");\n } catch {\n preview = \"\";\n }\n\n return {\n \"@type\": typeName,\n byteLength,\n ...(preview ? { preview } : {}),\n ...(byteLength > BINARY_PREVIEW_BYTES\n ? { \"@truncated\": { kind: \"bytes\", omitted: byteLength - BINARY_PREVIEW_BYTES } }\n : {}),\n };\n}\n\n/** Convert an array, keeping at most maxArrayLength entries */\nfunction normalizeArray(\n value: unknown[],\n options: ResolvedOptions,\n depth: number,\n path: string,\n ancestors: Map<object, string>\n): JsonValue {\n const kept: JsonValue[] = [];\n const limit = Math.min(value.length, options.maxArrayLength);\n\n for (let index = 0; index < limit; index += 1) {\n kept.push(\n normalizeUnknown(value[index], options, depth + 1, `${path}[${index}]`, ancestors)\n );\n }\n\n if (value.length > limit) {\n kept.push({ \"@truncated\": { kind: \"array\", omitted: value.length - limit } });\n }\n\n return kept;\n}\n\n/** Convert a plain object or class instance, tagging the class name when there is one */\nfunction normalizeObject(\n value: object,\n options: ResolvedOptions,\n depth: number,\n path: string,\n ancestors: Map<object, string>\n): JsonValue {\n const result: Record<string, JsonValue> = {};\n\n const className = readConstructorName(value);\n if (className && className !== \"Object\") {\n result[\"@type\"] = className;\n }\n\n let keys: string[];\n try {\n keys = Object.keys(value);\n } catch {\n return `[Unreadable: keys could not be listed]`;\n }\n\n let kept = 0;\n let omitted = 0;\n for (const key of keys) {\n if (kept >= options.maxProperties) {\n omitted += 1;\n continue;\n }\n\n let propertyValue: unknown;\n try {\n propertyValue = (value as Record<string, unknown>)[key];\n } catch (failure) {\n // A getter that throws must not take the whole record down with it\n result[key] = `[Unreadable: ${describeFailure(failure)}]`;\n kept += 1;\n continue;\n }\n\n // JSON.stringify drops undefined properties; match that so records stay clean\n if (propertyValue === undefined) continue;\n\n result[key] = redactOrNormalize(\n key,\n propertyValue,\n options,\n depth + 1,\n `${path}.${key}`,\n ancestors\n );\n kept += 1;\n }\n\n // Only count properties dropped for the size limit — a property skipped because\n // its value was undefined is absent from JSON.stringify output too, not truncated\n if (omitted) {\n result[\"@truncated\"] = { kind: \"properties\", omitted };\n }\n\n return result;\n}\n\n/** Replace secret-shaped values with the redaction marker, otherwise normalize normally */\nfunction redactOrNormalize(\n key: string,\n value: unknown,\n options: ResolvedOptions,\n depth: number,\n path: string,\n ancestors: Map<object, string>\n): JsonValue {\n if (options.redact && options.redactKeys.has(normalizeKeyForMatching(key))) {\n return REDACTED;\n }\n if (options.redact && options.redactValues !== \"off\" && keyLooksSecret(key, value)) {\n return REDACTED;\n }\n return normalizeUnknown(value, options, depth, path, ancestors);\n}\n\n/** Replace recognisable secrets inside a string, when redaction is on */\nfunction redactText(value: string, options: ResolvedOptions): string {\n return options.redact ? redactString(value, options.redactValues) : value;\n}\n\n/** Read a value's class name, tolerating null-prototype objects and hostile proxies */\nfunction readConstructorName(value: object): string | undefined {\n try {\n const prototype = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null;\n if (prototype === null) return undefined;\n const name = prototype.constructor?.name;\n return typeof name === \"string\" && name.length ? name : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Check whether a value is a non-array object with an ordinary prototype */\nfunction isPlainRecord(value: unknown): boolean {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/** Cut a string to length, marking inline how many characters were dropped */\nfunction truncateString(value: string, maxLength: number): string {\n if (value.length <= maxLength) return value;\n return `${value.slice(0, maxLength)}…[+${value.length - maxLength} chars]`;\n}\n\n/** Convert any value to a short string without risking a throw from a custom toString */\nfunction safeStringify(value: unknown, maxLength: number): string {\n try {\n return truncateString(String(value), maxLength);\n } catch {\n return \"[unstringifiable]\";\n }\n}\n\n/** Extract a readable message from a value thrown while normalizing */\nfunction describeFailure(failure: unknown): string {\n if (failure instanceof Error && failure.message) return failure.message;\n try {\n return String(failure);\n } catch {\n return \"unknown error\";\n }\n}\n","import type { StoryLevel } from \"./storyteller\";\n\n/**\n * Which default audience a storyteller registers.\n *\n * - `text` — colorized console output for a person watching\n * - `ndjson` — one JSON object per line for a program reading\n */\nexport type OutputFormat = \"text\" | \"ndjson\";\n\n/** Rank levels so a minimum threshold can be compared numerically */\nconst LEVEL_RANK: Record<StoryLevel, number> = {\n Information: 0,\n Warning: 1,\n Error: 2,\n};\n\n/** Accepted spellings for a level, in env vars and options alike */\nconst LEVEL_ALIASES: Record<string, StoryLevel> = {\n info: \"Information\",\n information: \"Information\",\n tell: \"Information\",\n warn: \"Warning\",\n warning: \"Warning\",\n oops: \"Error\",\n error: \"Error\",\n};\n\n/**\n * A level written any of the ways people and agents actually write it.\n * `report(\"...\", { level: \"warn\" })` should not be a type error.\n */\nexport type LevelInput =\n | StoryLevel\n | \"info\"\n | \"information\"\n | \"warn\"\n | \"warning\"\n | \"oops\"\n | \"error\";\n\n/**\n * Resolve any accepted level spelling to a stored level label.\n *\n * @param input - A level in any accepted spelling\n * @returns The canonical StoryLevel, defaulting to Information\n */\nexport function toStoryLevel(input?: LevelInput): StoryLevel {\n if (!input) return \"Information\";\n return LEVEL_ALIASES[String(input).toLowerCase()] ?? \"Information\";\n}\n\n/**\n * Read an environment variable, tolerating runtimes that have no environment at all.\n *\n * @param name - Variable name\n * @returns The trimmed value, or undefined when unset or unavailable\n */\nexport function readEnvironmentValue(name: string): string | undefined {\n try {\n const runtime = globalThis as {\n process?: { env?: Record<string, string | undefined> };\n };\n const value = runtime.process?.env?.[name];\n return typeof value === \"string\" && value.length ? value.trim() : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Resolve the minimum level to deliver, from an explicit option then `STORYTELLER_LEVEL`.\n *\n * @param requested - Explicit level, if the caller set one\n * @returns The threshold level, defaulting to Information (deliver everything)\n */\nexport function resolveMinimumLevel(requested?: StoryLevel | string): StoryLevel {\n const value = requested ?? readEnvironmentValue(\"STORYTELLER_LEVEL\");\n if (!value) return \"Information\";\n return LEVEL_ALIASES[String(value).toLowerCase()] ?? \"Information\";\n}\n\n/**\n * Check whether a level clears the configured minimum.\n *\n * @param level - The emission's level\n * @param minimum - The configured threshold\n */\nexport function meetsLevel(level: StoryLevel, minimum: StoryLevel): boolean {\n return LEVEL_RANK[level] >= LEVEL_RANK[minimum];\n}\n\n/**\n * Resolve which default audience to register, from an explicit option then\n * `STORYTELLER_FORMAT`.\n *\n * Deliberately not inferred from whether stdout is a TTY: output that silently\n * changes shape when a process is piped is a debugging afternoon nobody asked for.\n *\n * @param requested - Explicit format, if the caller set one\n * @returns The format, defaulting to text\n */\nexport function resolveOutputFormat(requested?: OutputFormat): OutputFormat {\n const value = requested ?? readEnvironmentValue(\"STORYTELLER_FORMAT\");\n return value === \"ndjson\" ? \"ndjson\" : \"text\";\n}\n\n/**\n * Resolve whether to colorize, from an explicit option then `STORYTELLER_COLOR`.\n *\n * @param requested - Explicit choice, if the caller set one\n * @returns Whether colors should be used, defaulting to true\n */\nexport function resolveColors(requested?: boolean): boolean {\n if (requested !== undefined) return requested;\n\n const value = readEnvironmentValue(\"STORYTELLER_COLOR\");\n if (value === undefined) return true;\n\n return !(value === \"0\" || value.toLowerCase() === \"false\");\n}\n","/**\n * The StoryStore contract: where stories go, and how they come back.\n *\n * A story is the unit of retrieval — self-contained, ordered, small enough to\n * fit in a context window. The contract is deliberately small: append, get by\n * id, query by structured criteria, walk chapters, and forget. Every adapter\n * (in memory, a file, SQLite, Postgres) answers the same questions, so the\n * questions — not each adapter's query language — are the contract.\n */\nimport type { StoryEvent, StoryEventBase, StoryLevel, StoryNote, StoryOrigin } from \"../storyteller\";\nimport type { JsonValue } from \"../normalize\";\nimport { DEFAULT_REDACT_KEYS } from \"../normalize\";\nimport { normalizeKeyForMatching, redactJson, type RedactionStrictness } from \"../redaction\";\nimport { meetsLevel } from \"../environment\";\n\n/** A story as kept: the record without the `summarize` method, with an id it can be fetched by */\nexport type StoredStory = StoryEventBase & { storyId: string };\n\n/**\n * Structured criteria. Every field narrows; an empty query matches everything.\n * Built by hand or by the query vocabulary; adapters receive this object,\n * never a string to parse.\n */\nexport type StoryQuery = {\n /** Stories whose timestamp is at or after this instant */\n since?: Date;\n /** Stories whose timestamp is before this instant */\n until?: Date;\n /** Exactly this level, or any of these */\n level?: StoryLevel | StoryLevel[];\n /** At least this level: Warning matches Warning and Error */\n minimumLevel?: StoryLevel;\n /** Case-insensitive match over the title, note text, scalar context and error messages */\n about?: string;\n /** Case-insensitive match over the origin's who / what / where */\n from?: string;\n /** Chapters of this story */\n parentStoryId?: string;\n /** Stories that took longer than this */\n slowerThanMs?: number;\n /** Stories that carry an error or closed at Error level */\n failed?: boolean;\n limit?: number;\n offset?: number;\n /** Newest first unless told otherwise */\n order?: \"newest\" | \"oldest\";\n};\n\nexport type StoryStore = {\n /** Keep a story. Replaces an earlier record with the same id. Never throws synchronously. */\n append(event: StoryEvent | StoredStory): Promise<void>;\n /** One story, complete, by id */\n get(storyId: string): Promise<StoredStory | undefined>;\n /** Stories matching the criteria, newest first by default */\n query(criteria?: StoryQuery): Promise<StoredStory[]>;\n /** The chapters of a story, in the order they were told */\n children(parentStoryId: string): Promise<StoredStory[]>;\n /** Forget stories that began before the boundary; returns how many were removed */\n prune(before: Date): Promise<number>;\n};\n\nexport type ToStoredStoryOptions = {\n /**\n * Redaction at the storage boundary. The normalizer already redacted at\n * capture; this pass covers a record fed to a store by hand, or one\n * normalized with redaction off, before it becomes durable. Default\n * `balanced`; `off` trusts the record as given.\n */\n redactValues?: RedactionStrictness;\n};\n\nconst DEFAULT_KEY_SET = new Set(DEFAULT_REDACT_KEYS.map(normalizeKeyForMatching));\n\n/**\n * The record as a store keeps it: a deep copy of the JSON-safe fields, so a\n * caller holding the event cannot change what was stored, and no method rides\n * along. A record without an id — one written before ids existed — is given\n * one. Secrets are redacted once more on the way in: persisted is the moment\n * a leaked value stops being a line that scrolled past.\n */\nexport function toStoredStory(event: StoryEvent | StoredStory, options: ToStoredStoryOptions = {}): StoredStory {\n const { timestamp, level, title, storyId, parentStoryId, origin, notes, durationMs, droppedEmissions, error } = event;\n const strictness = options.redactValues ?? \"balanced\";\n const scrub = <T>(value: T): T =>\n strictness === \"off\"\n ? clone(value)\n : (redactJson(clone(value) as unknown as JsonValue, { redactKeys: DEFAULT_KEY_SET, strictness }) as unknown as T);\n const record: StoredStory = {\n timestamp,\n level,\n title: scrub(title),\n storyId: storyId ?? generateStoryId(),\n notes: scrub(notes) as StoryNote[],\n };\n if (parentStoryId !== undefined) record.parentStoryId = parentStoryId;\n if (origin !== undefined) record.origin = scrub(origin);\n if (durationMs !== undefined) record.durationMs = durationMs;\n if (droppedEmissions !== undefined) record.droppedEmissions = droppedEmissions;\n if (error !== undefined) record.error = scrub(error);\n return record;\n}\n\n/** Records are JSON-safe by construction, so a JSON round trip is an exact deep copy */\nfunction clone<T>(value: T): T {\n return JSON.parse(JSON.stringify(value)) as T;\n}\n\nfunction generateStoryId(): string {\n const random = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID;\n if (random) return random.call(globalThis.crypto);\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Everything a full-text search over a story should see: the title, each\n * note's text, scalar context values, and error messages. Adapters store this\n * as the canonical `search_text` column; the in-memory matcher searches it.\n */\nexport function storySearchText(story: StoredStory): string {\n const parts: string[] = [story.title];\n for (const note of story.notes) {\n parts.push(note.note);\n for (const value of [note.who, note.what, note.where]) {\n if (typeof value === \"string\") parts.push(value);\n }\n if (note.error?.message) parts.push(note.error.message);\n }\n if (story.error?.message) parts.push(story.error.message);\n return parts.join(\"\\n\");\n}\n\n/** The origin as three text columns: scalars as-is, objects as JSON */\nexport function flattenOrigin(origin?: StoryOrigin): { who?: string; what?: string; where?: string } {\n const flat: { who?: string; what?: string; where?: string } = {};\n if (!origin) return flat;\n for (const key of [\"who\", \"what\", \"where\"] as const) {\n const value = origin[key];\n if (value === undefined || value === null) continue;\n flat[key] = typeof value === \"object\" ? JSON.stringify(value) : String(value);\n }\n return flat;\n}\n\n/**\n * The canonical schema. An adapter that stores these columns can answer every\n * StoryQuery; `record` carries the whole story so `get` returns it unchanged.\n * Deliberately not a normalized notes table — nothing yet demands one.\n */\nexport type CanonicalRow = {\n story_id: string;\n parent_story_id: string | null;\n timestamp: string;\n level: StoryLevel;\n title: string;\n origin_who: string | null;\n origin_what: string | null;\n origin_where: string | null;\n duration_ms: number | null;\n error_message: string | null;\n /** The notes, as JSON */\n notes: string;\n /** Derived: what `about` searches */\n search_text: string;\n /** The whole story, as JSON */\n record: string;\n};\n\nexport function canonicalRow(event: StoryEvent | StoredStory): CanonicalRow {\n const story = toStoredStory(event);\n const origin = flattenOrigin(story.origin);\n return {\n story_id: story.storyId,\n parent_story_id: story.parentStoryId ?? null,\n timestamp: story.timestamp,\n level: story.level,\n title: story.title,\n origin_who: origin.who ?? null,\n origin_what: origin.what ?? null,\n origin_where: origin.where ?? null,\n duration_ms: story.durationMs ?? null,\n error_message: story.error?.message ?? null,\n notes: JSON.stringify(story.notes),\n search_text: storySearchText(story),\n record: JSON.stringify(story),\n };\n}\n\n/** Does one story satisfy the criteria? The reference matcher every adapter is measured against. */\nexport function matchesQuery(story: StoredStory, query: StoryQuery = {}): boolean {\n if (query.since && Date.parse(story.timestamp) < query.since.getTime()) return false;\n if (query.until && Date.parse(story.timestamp) >= query.until.getTime()) return false;\n if (query.level !== undefined) {\n const levels = Array.isArray(query.level) ? query.level : [query.level];\n if (!levels.includes(story.level)) return false;\n }\n if (query.minimumLevel && !meetsLevel(story.level, query.minimumLevel)) return false;\n if (query.parentStoryId !== undefined && story.parentStoryId !== query.parentStoryId) return false;\n if (query.slowerThanMs !== undefined && !((story.durationMs ?? -1) > query.slowerThanMs)) return false;\n if (query.failed !== undefined) {\n const failed = story.level === \"Error\" || story.error !== undefined;\n if (failed !== query.failed) return false;\n }\n if (query.about) {\n if (!storySearchText(story).toLowerCase().includes(query.about.toLowerCase())) return false;\n }\n if (query.from) {\n const origin = Object.values(flattenOrigin(story.origin)).join(\"\\n\").toLowerCase();\n if (!origin.includes(query.from.toLowerCase())) return false;\n }\n return true;\n}\n\n/** Filter, order and page a set of stories in memory — what a store without an index does */\nexport function applyQuery(stories: Iterable<StoredStory>, query: StoryQuery = {}): StoredStory[] {\n // Two stories in the same millisecond keep the order they were kept in,\n // so a page of results is stable across calls and across adapters\n const matched: { story: StoredStory; position: number }[] = [];\n let position = 0;\n for (const story of stories) {\n if (matchesQuery(story, query)) matched.push({ story, position });\n position++;\n }\n const direction = query.order === \"oldest\" ? 1 : -1;\n matched.sort((a, b) => {\n const byTime = Date.parse(a.story.timestamp) - Date.parse(b.story.timestamp);\n return (byTime !== 0 ? byTime : a.position - b.position) * direction;\n });\n const offset = Math.max(0, query.offset ?? 0);\n const end = query.limit !== undefined ? offset + Math.max(0, query.limit) : undefined;\n return matched.slice(offset, end).map((entry) => entry.story);\n}\n","/**\n * A StoryStore on one JSON-lines file. Node only — it needs the filesystem —\n * so it ships from `@lovelaces-io/storyteller/store/file`, never from the root.\n */\nimport { appendFile, mkdir, readFile, rename, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport type { StoryEvent } from \"../storyteller\";\nimport type { StoredStory, StoryQuery, StoryStore } from \"./storyStore\";\nimport { applyQuery, toStoredStory } from \"./storyStore\";\n\nexport type FileStore = StoryStore & {\n /** Where the stories are */\n readonly path: string;\n /** Resolves once every write queued so far has reached the file — call before exiting */\n flush(): Promise<void>;\n};\n\n/**\n * Append-only: `append` writes one line, reads parse the file, `prune`\n * rewrites it without the old stories. Simple on purpose. Right for a\n * single process and a few hundred thousand stories; past that, an adapter\n * with an index is the next step, and it answers the same queries.\n *\n * Writes are serialized so two appends cannot interleave and a prune cannot\n * race an append. A line that does not parse — a crash mid-write — is skipped,\n * never fatal: a store that cannot read its own file is not much of a store.\n *\n * @example\n * ```ts\n * import { fileStore } from \"@lovelaces-io/storyteller/store/file\";\n * const stories = fileStore(\"./stories.jsonl\");\n * story.audience.add(storeAudience(stories));\n * ```\n */\nexport function fileStore(path: string): FileStore {\n // Every write waits for the one before it\n let queue: Promise<unknown> = Promise.resolve();\n const serialized = <T>(work: () => Promise<T>): Promise<T> => {\n const next = queue.then(work, work);\n queue = next.catch(() => undefined);\n return next;\n };\n\n async function readAll(): Promise<Map<string, StoredStory>> {\n let text: string;\n try {\n text = await readFile(path, \"utf8\");\n } catch (error) {\n if ((error as { code?: string }).code === \"ENOENT\") return new Map();\n throw error;\n }\n const stories = new Map<string, StoredStory>();\n for (const line of text.split(\"\\n\")) {\n if (!line.trim()) continue;\n try {\n const story = JSON.parse(line) as StoredStory;\n if (typeof story?.storyId === \"string\" && typeof story.timestamp === \"string\") {\n // Last write wins, and moves the story to the end like the memory store\n stories.delete(story.storyId);\n stories.set(story.storyId, story);\n }\n } catch {\n // a torn line\n }\n }\n return stories;\n }\n\n return {\n path,\n async flush() {\n await queue;\n },\n async append(event: StoryEvent | StoredStory) {\n const line = `${JSON.stringify(toStoredStory(event))}\\n`;\n await serialized(async () => {\n await mkdir(dirname(path), { recursive: true });\n await appendFile(path, line, \"utf8\");\n });\n },\n async get(storyId: string) {\n return (await readAll()).get(storyId);\n },\n async query(criteria: StoryQuery = {}) {\n return applyQuery((await readAll()).values(), criteria);\n },\n async children(parentStoryId: string) {\n return applyQuery((await readAll()).values(), { parentStoryId, order: \"oldest\" });\n },\n async prune(before: Date) {\n return serialized(async () => {\n const stories = await readAll();\n const boundary = before.getTime();\n const kept: StoredStory[] = [];\n let removed = 0;\n for (const story of stories.values()) {\n if (Date.parse(story.timestamp) < boundary) removed++;\n else kept.push(story);\n }\n if (removed === 0) return 0;\n const temporary = `${path}.${process.pid}.tmp`;\n await writeFile(temporary, kept.map((story) => JSON.stringify(story)).join(\"\\n\") + (kept.length ? \"\\n\" : \"\"), \"utf8\");\n await rename(temporary, path);\n return removed;\n });\n },\n };\n}\n","/**\n * The query vocabulary: ask a store a question in the library's own words.\n *\n * await stories(store).about(\"checkout\").from(\"payment-service\").failing().since(\"24h\");\n *\n * The same words have to read well aloud and be easy for a model to write.\n * Every clause returns a new builder; a builder compiles to a StoryQuery, so\n * an adapter receives structured criteria and never a string to parse.\n */\nimport type { LevelInput } from \"../environment\";\nimport { toStoryLevel } from \"../environment\";\nimport type { StoredStory, StoryQuery, StoryStore } from \"./storyStore\";\n\n/** `\"30s\"`, `\"5m\"`, `\"24h\"`, `\"7d\"`, `\"2w\"`, or milliseconds */\nexport type DurationInput = string | number;\n\nconst UNITS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n};\n\n/** A duration as milliseconds. Throws on something that is not a duration, so a typo cannot become \"since forever\". */\nexport function parseDuration(input: DurationInput): number {\n if (typeof input === \"number\") {\n if (!Number.isFinite(input) || input < 0) throw new RangeError(`Not a duration: ${input}`);\n return input;\n }\n const match = /^\\s*(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d|w)\\s*$/i.exec(input);\n if (!match) throw new RangeError(`Not a duration: \"${input}\" (expected e.g. \"30s\", \"5m\", \"24h\", \"7d\", \"2w\")`);\n return Number(match[1]) * UNITS[match[2]!.toLowerCase()]!;\n}\n\nexport type StoriesOptions = {\n /** The clock `since(\"24h\")` counts back from. Injectable for tests. */\n now?: () => Date;\n};\n\n/**\n * A question in progress. Immutable: every clause returns a new one.\n * Awaiting it runs `.all()`.\n *\n * Being thenable has one consequence: returning a builder from an `async`\n * function resolves it into its results. Return the store, or the\n * `toQuery()` object, when a caller should get the question rather than\n * the answer.\n */\nexport class StoryQueryBuilder implements PromiseLike<StoredStory[]> {\n constructor(\n private readonly store: StoryStore,\n private readonly query: StoryQuery,\n private readonly now: () => Date\n ) {}\n\n private with(patch: StoryQuery): StoryQueryBuilder {\n return new StoryQueryBuilder(this.store, { ...this.query, ...patch }, this.now);\n }\n\n private instant(input: DurationInput | Date): Date {\n return input instanceof Date ? input : new Date(this.now().getTime() - parseDuration(input));\n }\n\n /** Title, note text, scalar context or error message mentions this */\n about(text: string): StoryQueryBuilder {\n return this.with({ about: text });\n }\n\n /** The origin — who, what or where — mentions this */\n from(origin: string): StoryQueryBuilder {\n return this.with({ from: origin });\n }\n\n /** Exactly this level. Accepts the aliases: `\"info\"`, `\"warn\"`, `\"oops\"`. */\n level(level: LevelInput): StoryQueryBuilder {\n return this.with({ level: toStoryLevel(level) });\n }\n\n /** This level or worse */\n atLeast(level: LevelInput): StoryQueryBuilder {\n return this.with({ minimumLevel: toStoryLevel(level) });\n }\n\n /** Carried an error, or closed at Error level */\n failing(): StoryQueryBuilder {\n return this.with({ failed: true });\n }\n\n /** Neither an error nor closed at Error level */\n succeeding(): StoryQueryBuilder {\n return this.with({ failed: false });\n }\n\n /** Took longer than this: `\"5s\"`, `\"2m\"`, or milliseconds */\n slowerThan(duration: DurationInput): StoryQueryBuilder {\n return this.with({ slowerThanMs: parseDuration(duration) });\n }\n\n /** Began within this long ago (`\"24h\"`), or at or after this date */\n since(when: DurationInput | Date): StoryQueryBuilder {\n return this.with({ since: this.instant(when) });\n }\n\n /** Began before this long ago, or before this date */\n until(when: DurationInput | Date): StoryQueryBuilder {\n return this.with({ until: this.instant(when) });\n }\n\n /** The chapters of this story */\n under(parentStoryId: string): StoryQueryBuilder {\n return this.with({ parentStoryId });\n }\n\n /** Most recent first — the default */\n newest(): StoryQueryBuilder {\n return this.with({ order: \"newest\" });\n }\n\n /** Earliest first */\n oldest(): StoryQueryBuilder {\n return this.with({ order: \"oldest\" });\n }\n\n /** At most this many */\n limit(count: number): StoryQueryBuilder {\n return this.with({ limit: count });\n }\n\n /** Skip this many first */\n skip(count: number): StoryQueryBuilder {\n return this.with({ offset: count });\n }\n\n /** The structured criteria this question compiles to — what an adapter receives */\n toQuery(): StoryQuery {\n return { ...this.query };\n }\n\n /** Every matching story */\n all(): Promise<StoredStory[]> {\n return this.store.query(this.toQuery());\n }\n\n /** The first match, or nothing */\n async first(): Promise<StoredStory | undefined> {\n const [story] = await this.store.query({ ...this.query, limit: 1 });\n return story;\n }\n\n /** How many match, ignoring paging */\n async count(): Promise<number> {\n const { limit: _limit, offset: _offset, ...unpaged } = this.query;\n return (await this.store.query(unpaged)).length;\n }\n\n /** An awaited builder resolves to `.all()` */\n then<Result = StoredStory[], Failure = never>(\n onFulfilled?: ((stories: StoredStory[]) => Result | PromiseLike<Result>) | null,\n onRejected?: ((reason: unknown) => Failure | PromiseLike<Failure>) | null\n ): Promise<Result | Failure> {\n return this.all().then(onFulfilled, onRejected);\n }\n}\n\n/**\n * Start a question against a store.\n *\n * @example\n * ```ts\n * const recent = stories(store);\n * await recent.failing().since(\"1h\");\n * await recent.about(\"checkout\").from(\"payment-service\").level(\"oops\").since(\"24h\");\n * await recent.slowerThan(\"5s\").since(\"7d\").oldest().limit(10);\n * await recent.under(parentStoryId).count();\n * ```\n */\nexport function stories(store: StoryStore, options: StoriesOptions = {}): StoryQueryBuilder {\n return new StoryQueryBuilder(store, {}, options.now ?? (() => new Date()));\n}\n"],"mappings":"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","names":["stories","stories"]}
|